// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.
using System.Formats.Tar;
using System.IO.Compression;
using System.Net;
using System.Security.Cryptography;
using System.Text.Json;
using Aspire.Cli.Agents;
using Aspire.Cli.Agents.AspireSkills;
using Aspire.Cli.Configuration;
using Aspire.Cli.Npm;
using Aspire.Cli.Tests.Telemetry;
using Aspire.Cli.Tests.TestServices;
using Aspire.Cli.Tests.Utils;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Extensions.Logging.Testing;
namespace Aspire.Cli.Tests.Agents;
public class AspireSkillsInstallerTests
{
private const string AspireSkillDescription = "Aspire CLI commands and workflows for distributed apps";
private const string CacheLockRetryLogMessage = "Acquiring the Aspire skills cache lock";
private const string GitHubReleaseAssetBuildType = "https://actions.github.io/buildtypes/workflow/v1";
[Fact]
public async Task InstallAsync_WhenValidBundleIsCached_UsesCacheWithoutNetwork()
{
var rootDirectory = CreateTempDirectory();
try
{
var executionContext = TestExecutionContextHelper.CreateExecutionContext(new DirectoryInfo(rootDirectory));
var embeddedBundleProvider = await CreateEmbeddedBundleProviderAsync();
var cachedBundleDirectory = GetBundleCacheDirectory(executionContext, embeddedBundleProvider.Metadata!.Sha512!);
await CreateCachedBundleAsync(cachedBundleDirectory, archiveSha512: embeddedBundleProvider.Metadata!.Sha512);
var handler = new MockHttpMessageHandler(_ => throw new InvalidOperationException("HTTP must not be called when remote fetch is disabled."));
var features = new TestFeatures().SetFeature(KnownFeatures.AspireSkillsRemoteFetchEnabled, false);
var installer = CreateInstaller(
executionContext,
httpMessageHandler: handler,
embeddedBundleProvider: embeddedBundleProvider,
features: features);
var result = await installer.InstallAsync(CancellationToken.None);
Assert.Equal(AspireSkillsInstallStatus.Installed, result.Status);
Assert.NotNull(result.Bundle);
Assert.True(Directory.Exists(cachedBundleDirectory));
Assert.False(embeddedBundleProvider.CreateBundleCalled);
}
finally
{
Directory.Delete(rootDirectory, recursive: true);
}
}
[Theory]
[InlineData(unchecked((int)0x80070020), true, true)]
[InlineData(unchecked((int)0x80070021), true, true)]
[InlineData(unchecked((int)0x80070005), true, false)]
[InlineData(unchecked((int)0x80070070), true, false)]
[InlineData(11, false, true)]
[InlineData(35, false, true)]
[InlineData(2, false, false)]
[InlineData(28, false, false)]
public void IsCacheLockContention_OnlyMatchesPlatformLockErrors(int hresult, bool isWindows, bool expected)
{
var exception = new IOException("Cache lock failed.", hresult);
Assert.Equal(expected, AspireSkillsInstaller.IsCacheLockContention(exception, isWindows));
}
[Fact]
public async Task InstallAsync_WhenRequiredCacheLockExceedsCleanupRetryBudget_WaitsForRelease()
{
var rootDirectory = CreateTempDirectory();
try
{
var executionContext = TestExecutionContextHelper.CreateExecutionContext(new DirectoryInfo(rootDirectory));
var cacheRoot = Path.Combine(executionContext.CacheDirectory.FullName, "aspire-skills");
Directory.CreateDirectory(cacheRoot);
var embeddedBundleProvider = await CreateEmbeddedBundleProviderAsync();
var features = new TestFeatures().SetFeature(KnownFeatures.AspireSkillsRemoteFetchEnabled, false);
var cleanupRetryBudgetExceeded = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
var retryCount = 0;
var sink = new TestSink();
sink.MessageLogged += context =>
{
if (context.Message?.Contains(CacheLockRetryLogMessage, StringComparison.Ordinal) == true &&
Interlocked.Increment(ref retryCount) == 4)
{
cleanupRetryBudgetExceeded.TrySetResult();
}
};
var logger = new TestLogger<AspireSkillsInstaller>(new TestLoggerFactory(sink, enabled: true));
var installer = CreateInstaller(
executionContext,
embeddedBundleProvider: embeddedBundleProvider,
features: features,
logger: logger);
var cacheLockPath = Path.Combine(cacheRoot, $".{AspireSkillsInstaller.Version}.lock");
await using var cacheLock = new FileStream(
cacheLockPath,
FileMode.OpenOrCreate,
FileAccess.ReadWrite,
FileShare.None,
bufferSize: 1,
FileOptions.Asynchronous);
var installTask = installer.InstallAsync(CancellationToken.None);
var completedTask = await Task.WhenAny(cleanupRetryBudgetExceeded.Task, installTask).WaitAsync(TimeSpan.FromSeconds(10));
Assert.Same(cleanupRetryBudgetExceeded.Task, completedTask);
Assert.False(installTask.IsCompleted);
await cacheLock.DisposeAsync();
var result = await installTask.WaitAsync(TimeSpan.FromSeconds(10));
Assert.Equal(AspireSkillsInstallStatus.Installed, result.Status);
}
finally
{
Directory.Delete(rootDirectory, recursive: true);
}
}
[Fact]
public async Task InstallAsync_WhenGitHubDigestMatchesCachedBundle_UsesCacheWithoutDownloadingAsset()
{
var rootDirectory = CreateTempDirectory();
try
{
var archiveBytes = await CreateBundleArchiveBytesAsync();
var archiveSha256 = ComputeSha256(archiveBytes);
var archiveSha512 = ComputeSha512(archiveBytes);
var executionContext = TestExecutionContextHelper.CreateExecutionContext(new DirectoryInfo(rootDirectory));
var cachedBundleDirectory = GetBundleCacheDirectory(executionContext, archiveSha512);
await CreateCachedBundleAsync(
cachedBundleDirectory,
archiveSha512: archiveSha512,
githubArchiveSha256: archiveSha256,
githubAttestationVerified: true);
var assetDownloadRequested = false;
var handler = new MockHttpMessageHandler(request =>
{
if (request.RequestUri!.AbsolutePath.EndsWith("/releases/tags/v0.0.1", StringComparison.Ordinal))
{
return CreateJsonResponse(CreateGitHubReleaseJson(
"aspire-skills-v0.0.1.tgz",
"https://downloads.example.test/aspire-skills-v0.0.1.tgz",
$"sha256:{archiveSha256}"));
}
assetDownloadRequested = true;
return new HttpResponseMessage(HttpStatusCode.InternalServerError);
});
var attestationVerifier = new TestGitHubArtifactAttestationVerifier();
var installer = CreateInstaller(
executionContext,
httpMessageHandler: handler,
githubArtifactAttestationVerifier: attestationVerifier);
var result = await installer.InstallAsync(CancellationToken.None);
Assert.Equal(AspireSkillsInstallStatus.Installed, result.Status);
Assert.NotNull(result.Bundle);
Assert.False(assetDownloadRequested);
Assert.False(attestationVerifier.VerifyCalled);
}
finally
{
Directory.Delete(rootDirectory, recursive: true);
}
}
[Fact]
public async Task InstallAsync_WhenGitHubDigestMatchesUnverifiedCache_DownloadsAttestsAndReplacesCache()
{
var rootDirectory = CreateTempDirectory();
try
{
var archiveBytes = await CreateBundleArchiveBytesAsync(skillBody: "# Downloaded GitHub");
var archiveSha256 = ComputeSha256(archiveBytes);
var archiveSha512 = ComputeSha512(archiveBytes);
var executionContext = TestExecutionContextHelper.CreateExecutionContext(new DirectoryInfo(rootDirectory));
var cachedBundleDirectory = GetBundleCacheDirectory(executionContext, archiveSha512);
await CreateCachedBundleAsync(
cachedBundleDirectory,
archiveSha512: archiveSha512,
githubArchiveSha256: archiveSha256,
skillBody: "# Unverified Cache");
var assetDownloadRequested = false;
var handler = new MockHttpMessageHandler(request =>
{
if (request.RequestUri!.AbsolutePath.EndsWith("/releases/tags/v0.0.1", StringComparison.Ordinal))
{
return CreateJsonResponse(CreateGitHubReleaseJson(
"aspire-skills-v0.0.1.tgz",
"https://downloads.example.test/aspire-skills-v0.0.1.tgz",
$"sha256:{archiveSha256}"));
}
assetDownloadRequested = true;
return new HttpResponseMessage(HttpStatusCode.OK)
{
Content = new ByteArrayContent(archiveBytes)
};
});
var attestationVerifier = new TestGitHubArtifactAttestationVerifier();
var installer = CreateInstaller(
executionContext,
httpMessageHandler: handler,
githubArtifactAttestationVerifier: attestationVerifier);
var result = await installer.InstallAsync(CancellationToken.None);
Assert.Equal(AspireSkillsInstallStatus.Installed, result.Status);
Assert.NotNull(result.Bundle);
Assert.True(assetDownloadRequested);
Assert.True(attestationVerifier.VerifyCalled);
Assert.True(File.Exists(Path.Combine(
cachedBundleDirectory,
AspireSkillsInstaller.GitHubAttestationVerifiedFileName)));
var skill = Assert.Single(result.Bundle.GetSkillDefinitions());
var skillFile = Assert.Single(await result.Bundle.GetSkillFilesAsync(skill, CancellationToken.None));
Assert.Contains("# Downloaded GitHub", skillFile.Content, StringComparison.Ordinal);
}
finally
{
Directory.Delete(rootDirectory, recursive: true);
}
}
[Fact]
public async Task InstallAsync_WhenUnverifiedArchiveContainsAttestationMarker_DoesNotTrustCachedBundle()
{
var rootDirectory = CreateTempDirectory();
try
{
var archiveBytes = await CreateBundleArchiveBytesAsync(includeGitHubAttestationMarker: true);
var archiveSha256 = ComputeSha256(archiveBytes);
var archiveSha512 = ComputeSha512(archiveBytes);
var handler = new MockHttpMessageHandler(request =>
{
if (request.RequestUri!.AbsolutePath.EndsWith("/releases/tags/v0.0.1", StringComparison.Ordinal))
{
return CreateJsonResponse(CreateGitHubReleaseJson(
"aspire-skills-v0.0.1.tgz",
"https://downloads.example.test/aspire-skills-v0.0.1.tgz",
$"sha256:{archiveSha256}"));
}
return new HttpResponseMessage(HttpStatusCode.OK)
{
Content = new ByteArrayContent(archiveBytes)
};
});
var configuration = new ConfigurationBuilder()
.AddInMemoryCollection(new Dictionary<string, string?>
{
[AspireSkillsInstaller.DisablePackageValidationKey] = "true"
})
.Build();
var executionContext = TestExecutionContextHelper.CreateExecutionContext(new DirectoryInfo(rootDirectory));
var installer = CreateInstaller(
executionContext,
httpMessageHandler: handler,
configuration: configuration);
var firstResult = await installer.InstallAsync(CancellationToken.None);
Assert.Equal(AspireSkillsInstallStatus.Installed, firstResult.Status);
Assert.False(File.Exists(Path.Combine(
GetBundleCacheDirectory(executionContext, archiveSha512),
AspireSkillsInstaller.GitHubAttestationVerifiedFileName)));
var secondInstaller = CreateInstaller(executionContext);
var secondResult = await secondInstaller.InstallAsync(CancellationToken.None);
Assert.Equal(AspireSkillsInstallStatus.Failed, secondResult.Status);
}
finally
{
Directory.Delete(rootDirectory, recursive: true);
}
}
[Fact]
public async Task InstallAsync_WhenGitHubDigestChangesForCachedVersion_DownloadsAndKeepsBothCaches()
{
var rootDirectory = CreateTempDirectory();
try
{
var archiveBytes = await CreateBundleArchiveBytesAsync(skillBody: "# Downloaded GitHub");
var archiveSha256 = ComputeSha256(archiveBytes);
var archiveSha512 = ComputeSha512(archiveBytes);
var executionContext = TestExecutionContextHelper.CreateExecutionContext(new DirectoryInfo(rootDirectory));
var staleArchiveSha256 = new string('0', 64);
var staleArchiveSha512 = new string('0', 128);
var staleCachedBundleDirectory = GetBundleCacheDirectory(executionContext, staleArchiveSha512);
await CreateCachedBundleAsync(
staleCachedBundleDirectory,
archiveSha512: staleArchiveSha512,
githubArchiveSha256: staleArchiveSha256,
githubAttestationVerified: true,
skillBody: "# Stale GitHub");
var assetDownloadRequested = false;
var handler = new MockHttpMessageHandler(request =>
{
if (request.RequestUri!.AbsolutePath.EndsWith("/releases/tags/v0.0.1", StringComparison.Ordinal))
{
return CreateJsonResponse(CreateGitHubReleaseJson(
"aspire-skills-v0.0.1.tgz",
"https://downloads.example.test/aspire-skills-v0.0.1.tgz",
$"sha256:{archiveSha256}"));
}
assetDownloadRequested = true;
return new HttpResponseMessage(HttpStatusCode.OK)
{
Content = new ByteArrayContent(archiveBytes)
};
});
var installer = CreateInstaller(executionContext, httpMessageHandler: handler);
var result = await installer.InstallAsync(CancellationToken.None);
Assert.Equal(AspireSkillsInstallStatus.Installed, result.Status);
Assert.NotNull(result.Bundle);
Assert.True(assetDownloadRequested);
Assert.True(Directory.Exists(staleCachedBundleDirectory));
var cachedBundleDirectory = GetBundleCacheDirectory(executionContext, archiveSha512);
Assert.Equal(
archiveSha512,
await File.ReadAllTextAsync(Path.Combine(
cachedBundleDirectory,
AspireSkillsInstaller.ArchiveSha512FileName)));
Assert.Equal(
archiveSha256,
await File.ReadAllTextAsync(Path.Combine(
cachedBundleDirectory,
AspireSkillsInstaller.GitHubArchiveSha256FileName)));
var skill = Assert.Single(result.Bundle.GetSkillDefinitions());
var skillFile = Assert.Single(await result.Bundle.GetSkillFilesAsync(skill, CancellationToken.None));
Assert.Contains("# Downloaded GitHub", skillFile.Content, StringComparison.Ordinal);
}
finally
{
Directory.Delete(rootDirectory, recursive: true);
}
}
[Fact]
public async Task InstallAsync_WhenEmbeddedArchiveHashChangesForCachedVersion_KeepsBothCaches()
{
var rootDirectory = CreateTempDirectory();
try
{
var executionContext = TestExecutionContextHelper.CreateExecutionContext(new DirectoryInfo(rootDirectory));
var features = new TestFeatures().SetFeature(KnownFeatures.AspireSkillsRemoteFetchEnabled, false);
var oldEmbeddedBundleProvider = await CreateEmbeddedBundleProviderAsync(new SkillBundleSupports
{
AspireCli = ">=0.0.1 <0.0.2",
AspireSdk = ">=0.0.1 <0.0.2"
});
var oldInstaller = CreateInstaller(
executionContext,
embeddedBundleProvider: oldEmbeddedBundleProvider,
features: features);
var oldResult = await oldInstaller.InstallAsync(CancellationToken.None);
Assert.Equal(AspireSkillsInstallStatus.Installed, oldResult.Status);
Assert.NotNull(oldResult.Bundle);
var oldCachedBundleDirectory = GetBundleCacheDirectory(
executionContext,
oldEmbeddedBundleProvider.Metadata!.Sha512!);
var embeddedBundleProvider = await CreateEmbeddedBundleProviderAsync();
Assert.NotEqual(oldEmbeddedBundleProvider.Metadata!.Sha512, embeddedBundleProvider.Metadata!.Sha512);
var installer = CreateInstaller(
executionContext,
embeddedBundleProvider: embeddedBundleProvider,
features: features);
var result = await installer.InstallAsync(CancellationToken.None);
Assert.Equal(AspireSkillsInstallStatus.Installed, result.Status);
Assert.NotNull(result.Bundle);
Assert.True(embeddedBundleProvider.CreateBundleCalled);
Assert.True(Directory.Exists(oldCachedBundleDirectory));
var cachedBundleDirectory = GetBundleCacheDirectory(
executionContext,
embeddedBundleProvider.Metadata.Sha512!);
Assert.Equal(
embeddedBundleProvider.Metadata.Sha512,
await File.ReadAllTextAsync(Path.Combine(
cachedBundleDirectory,
AspireSkillsInstaller.ArchiveSha512FileName)));
Assert.NotEmpty(await oldResult.Bundle.GetSkillFilesAsync(
oldResult.Bundle.GetSkillDefinitions()[0],
CancellationToken.None));
var restoredEmbeddedBundleProvider = new TestEmbeddedAspireSkillsBundleProvider
{
Metadata = oldEmbeddedBundleProvider.Metadata,
ArchiveBytes = oldEmbeddedBundleProvider.ArchiveBytes
};
var restoredInstaller = CreateInstaller(
executionContext,
embeddedBundleProvider: restoredEmbeddedBundleProvider,
features: features);
var restoredResult = await restoredInstaller.InstallAsync(CancellationToken.None);
Assert.Equal(AspireSkillsInstallStatus.Installed, restoredResult.Status);
Assert.False(restoredEmbeddedBundleProvider.CreateBundleCalled);
}
finally
{
Directory.Delete(rootDirectory, recursive: true);
}
}
[Fact]
public async Task InstallAsync_WhenCachedBundleDoesNotHaveArchiveHash_ReplacesLegacyCache()
{
var rootDirectory = CreateTempDirectory();
try
{
var executionContext = TestExecutionContextHelper.CreateExecutionContext(new DirectoryInfo(rootDirectory));
var cachedBundleDirectory = Path.Combine(executionContext.CacheDirectory.FullName, "aspire-skills", AspireSkillsInstaller.Version);
await CreateCachedBundleAsync(cachedBundleDirectory);
var embeddedBundleProvider = await CreateEmbeddedBundleProviderAsync();
var features = new TestFeatures().SetFeature(KnownFeatures.AspireSkillsRemoteFetchEnabled, false);
var installer = CreateInstaller(
executionContext,
embeddedBundleProvider: embeddedBundleProvider,
features: features);
var result = await installer.InstallAsync(CancellationToken.None);
Assert.Equal(AspireSkillsInstallStatus.Installed, result.Status);
Assert.NotNull(result.Bundle);
Assert.True(embeddedBundleProvider.CreateBundleCalled);
Assert.False(File.Exists(Path.Combine(cachedBundleDirectory, "skill-manifest.json")));
Assert.False(Directory.Exists(Path.Combine(cachedBundleDirectory, "skills")));
var bundleCacheDirectory = GetBundleCacheDirectory(
executionContext,
embeddedBundleProvider.Metadata!.Sha512!);
Assert.Equal(
embeddedBundleProvider.Metadata.Sha512,
await File.ReadAllTextAsync(Path.Combine(
bundleCacheDirectory,
AspireSkillsInstaller.ArchiveSha512FileName)));
}
finally
{
Directory.Delete(rootDirectory, recursive: true);
}
}
[Fact]
public async Task InstallAsync_WhenCachedManifestIsMalformed_ReplacesCache()
{
var rootDirectory = CreateTempDirectory();
try
{
var executionContext = TestExecutionContextHelper.CreateExecutionContext(new DirectoryInfo(rootDirectory));
var embeddedBundleProvider = await CreateEmbeddedBundleProviderAsync();
var cachedBundleDirectory = GetBundleCacheDirectory(executionContext, embeddedBundleProvider.Metadata!.Sha512!);
await CreateCachedBundleAsync(
cachedBundleDirectory,
archiveSha512: embeddedBundleProvider.Metadata!.Sha512);
await File.WriteAllTextAsync(Path.Combine(cachedBundleDirectory, "skill-manifest.json"), "{");
var features = new TestFeatures().SetFeature(KnownFeatures.AspireSkillsRemoteFetchEnabled, false);
var installer = CreateInstaller(
executionContext,
embeddedBundleProvider: embeddedBundleProvider,
features: features);
var result = await installer.InstallAsync(CancellationToken.None);
Assert.Equal(AspireSkillsInstallStatus.Installed, result.Status);
Assert.NotNull(result.Bundle);
Assert.True(embeddedBundleProvider.CreateBundleCalled);
var skill = Assert.Single(result.Bundle.GetSkillDefinitions());
var skillFile = Assert.Single(await result.Bundle.GetSkillFilesAsync(skill, CancellationToken.None));
Assert.Contains("# Aspire", skillFile.Content, StringComparison.Ordinal);
}
finally
{
Directory.Delete(rootDirectory, recursive: true);
}
}
[Fact]
public async Task InstallAsync_WhenCachedBundleLastUsedCannotBeTouched_UsesCacheWithoutNetwork()
{
var rootDirectory = CreateTempDirectory();
try
{
var executionContext = TestExecutionContextHelper.CreateExecutionContext(new DirectoryInfo(rootDirectory));
var embeddedBundleProvider = await CreateEmbeddedBundleProviderAsync();
var cachedBundleDirectory = GetBundleCacheDirectory(executionContext, embeddedBundleProvider.Metadata!.Sha512!);
await CreateCachedBundleAsync(cachedBundleDirectory, archiveSha512: embeddedBundleProvider.Metadata!.Sha512);
Directory.CreateDirectory(Path.Combine(cachedBundleDirectory, ".lastused"));
var handler = new MockHttpMessageHandler(_ => throw new InvalidOperationException("HTTP must not be called when remote fetch is disabled."));
var features = new TestFeatures().SetFeature(KnownFeatures.AspireSkillsRemoteFetchEnabled, false);
var installer = CreateInstaller(
executionContext,
httpMessageHandler: handler,
embeddedBundleProvider: embeddedBundleProvider,
features: features);
var result = await installer.InstallAsync(CancellationToken.None);
Assert.Equal(AspireSkillsInstallStatus.Installed, result.Status);
Assert.NotNull(result.Bundle);
Assert.True(Directory.Exists(cachedBundleDirectory));
Assert.False(embeddedBundleProvider.CreateBundleCalled);
}
finally
{
Directory.Delete(rootDirectory, recursive: true);
}
}
[Fact]
public async Task InstallAsync_WhenStaleCacheLastUsedIsOutOfRange_IgnoresInvalidMarker()
{
var rootDirectory = CreateTempDirectory();
try
{
var executionContext = TestExecutionContextHelper.CreateExecutionContext(new DirectoryInfo(rootDirectory));
var embeddedBundleProvider = await CreateEmbeddedBundleProviderAsync();
var cachedBundleDirectory = GetBundleCacheDirectory(executionContext, embeddedBundleProvider.Metadata!.Sha512!);
await CreateCachedBundleAsync(cachedBundleDirectory, archiveSha512: embeddedBundleProvider.Metadata!.Sha512);
var staleArchiveSha512 = new string('a', 128);
var staleCacheDirectory = Path.Combine(
executionContext.CacheDirectory.FullName,
"aspire-skills",
"9.9.9",
staleArchiveSha512);
await CreateCachedBundleAsync(staleCacheDirectory, archiveSha512: staleArchiveSha512);
await File.WriteAllTextAsync(
Path.Combine(staleCacheDirectory, ".lastused"),
long.MaxValue.ToString(System.Globalization.CultureInfo.InvariantCulture));
var handler = new MockHttpMessageHandler(_ => throw new InvalidOperationException("HTTP must not be called when remote fetch is disabled."));
var features = new TestFeatures().SetFeature(KnownFeatures.AspireSkillsRemoteFetchEnabled, false);
var installer = CreateInstaller(
executionContext,
httpMessageHandler: handler,
embeddedBundleProvider: embeddedBundleProvider,
features: features);
var result = await installer.InstallAsync(CancellationToken.None);
Assert.Equal(AspireSkillsInstallStatus.Installed, result.Status);
Assert.NotNull(result.Bundle);
Assert.False(embeddedBundleProvider.CreateBundleCalled);
Assert.True(Directory.Exists(staleCacheDirectory));
}
finally
{
Directory.Delete(rootDirectory, recursive: true);
}
}
[Fact]
public async Task InstallAsync_RemovesStaleLegacyCacheForOtherVersion()
{
var rootDirectory = CreateTempDirectory();
try
{
var executionContext = TestExecutionContextHelper.CreateExecutionContext(new DirectoryInfo(rootDirectory));
var cacheRoot = Path.Combine(executionContext.CacheDirectory.FullName, "aspire-skills");
var staleVersionDirectory = Path.Combine(cacheRoot, "9.9.9");
await CreateCachedBundleAsync(staleVersionDirectory);
await WriteLastUsedAsync(staleVersionDirectory, DateTimeOffset.UtcNow.AddDays(-1));
var embeddedBundleProvider = await CreateEmbeddedBundleProviderAsync();
var currentCacheDirectory = GetBundleCacheDirectory(
executionContext,
embeddedBundleProvider.Metadata!.Sha512!);
await CreateCachedBundleAsync(
currentCacheDirectory,
archiveSha512: embeddedBundleProvider.Metadata.Sha512);
var configuration = new ConfigurationBuilder()
.AddInMemoryCollection(new Dictionary<string, string?>
{
[AspireSkillsInstaller.MaxCacheAgeKey] = "60"
})
.Build();
var features = new TestFeatures().SetFeature(KnownFeatures.AspireSkillsRemoteFetchEnabled, false);
var installer = CreateInstaller(
executionContext,
configuration: configuration,
embeddedBundleProvider: embeddedBundleProvider,
features: features);
var result = await installer.InstallAsync(CancellationToken.None);
Assert.Equal(AspireSkillsInstallStatus.Installed, result.Status);
Assert.False(Directory.Exists(staleVersionDirectory));
}
finally
{
Directory.Delete(rootDirectory, recursive: true);
}
}
[Fact]
public async Task InstallAsync_RemovesEmptyVersionDirectoryAfterStaleDigestCleanup()
{
var rootDirectory = CreateTempDirectory();
try
{
var executionContext = TestExecutionContextHelper.CreateExecutionContext(new DirectoryInfo(rootDirectory));
var cacheRoot = Path.Combine(executionContext.CacheDirectory.FullName, "aspire-skills");
var staleVersionDirectory = Path.Combine(cacheRoot, "9.9.9");
var staleArchiveSha512 = new string('a', 128);
var staleCacheDirectory = Path.Combine(staleVersionDirectory, staleArchiveSha512);
await CreateCachedBundleAsync(staleCacheDirectory, archiveSha512: staleArchiveSha512);
await WriteLastUsedAsync(staleCacheDirectory, DateTimeOffset.UtcNow.AddDays(-1));
var embeddedBundleProvider = await CreateEmbeddedBundleProviderAsync();
var currentCacheDirectory = GetBundleCacheDirectory(
executionContext,
embeddedBundleProvider.Metadata!.Sha512!);
await CreateCachedBundleAsync(
currentCacheDirectory,
archiveSha512: embeddedBundleProvider.Metadata.Sha512);
var configuration = new ConfigurationBuilder()
.AddInMemoryCollection(new Dictionary<string, string?>
{
[AspireSkillsInstaller.MaxCacheAgeKey] = "60"
})
.Build();
var features = new TestFeatures().SetFeature(KnownFeatures.AspireSkillsRemoteFetchEnabled, false);
var installer = CreateInstaller(
executionContext,
configuration: configuration,
embeddedBundleProvider: embeddedBundleProvider,
features: features);
var result = await installer.InstallAsync(CancellationToken.None);
Assert.Equal(AspireSkillsInstallStatus.Installed, result.Status);
Assert.False(Directory.Exists(staleVersionDirectory));
}
finally
{
Directory.Delete(rootDirectory, recursive: true);
}
}
[Fact]
public async Task InstallAsync_WhenStaleVersionLockIsReleasedDuringBackoff_RechecksLastUsed()
{
var rootDirectory = CreateTempDirectory();
try
{
var executionContext = TestExecutionContextHelper.CreateExecutionContext(new DirectoryInfo(rootDirectory));
var cacheRoot = Path.Combine(executionContext.CacheDirectory.FullName, "aspire-skills");
Directory.CreateDirectory(cacheRoot);
const string staleVersion = "9.9.9";
var staleArchiveSha512 = new string('a', 128);
var staleCacheDirectory = Path.Combine(cacheRoot, staleVersion, staleArchiveSha512);
await CreateCachedBundleAsync(staleCacheDirectory, archiveSha512: staleArchiveSha512);
await WriteLastUsedAsync(staleCacheDirectory, DateTimeOffset.UtcNow.AddDays(-1));
var configuration = new ConfigurationBuilder()
.AddInMemoryCollection(new Dictionary<string, string?>
{
[AspireSkillsInstaller.MaxCacheAgeKey] = "60"
})
.Build();
var embeddedBundleProvider = await CreateEmbeddedBundleProviderAsync();
var features = new TestFeatures().SetFeature(KnownFeatures.AspireSkillsRemoteFetchEnabled, false);
var retryObserved = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
var sink = new TestSink();
sink.MessageLogged += context =>
{
if (context.Message?.Contains(CacheLockRetryLogMessage, StringComparison.Ordinal) == true)
{
retryObserved.TrySetResult();
}
};
var logger = new TestLogger<AspireSkillsInstaller>(new TestLoggerFactory(sink, enabled: true));
var installer = CreateInstaller(
executionContext,
configuration: configuration,
embeddedBundleProvider: embeddedBundleProvider,
features: features,
logger: logger);
var staleVersionLockPath = Path.Combine(cacheRoot, $".{staleVersion}.lock");
await using var staleVersionLock = new FileStream(
staleVersionLockPath,
FileMode.OpenOrCreate,
FileAccess.ReadWrite,
FileShare.None,
bufferSize: 1,
FileOptions.Asynchronous);
var installTask = installer.InstallAsync(CancellationToken.None);
await retryObserved.Task.WaitAsync(TimeSpan.FromSeconds(10));
await WriteLastUsedAsync(staleCacheDirectory, DateTimeOffset.UtcNow);
await staleVersionLock.DisposeAsync();
var result = await installTask.WaitAsync(TimeSpan.FromSeconds(10));
Assert.Equal(AspireSkillsInstallStatus.Installed, result.Status);
Assert.True(Directory.Exists(staleCacheDirectory));
Assert.Contains(sink.Writes, context =>
context.Message?.Contains(CacheLockRetryLogMessage, StringComparison.Ordinal) == true);
Assert.Equal(0, sink.Writes.Count(context =>
context.Message?.Contains("Skipping cleanup", StringComparison.Ordinal) == true));
}
finally
{
Directory.Delete(rootDirectory, recursive: true);
}
}
[Fact]
public async Task InstallAsync_WhenStaleVersionLockExceedsRetryBudget_SkipsLockedVersion()
{
var rootDirectory = CreateTempDirectory();
try
{
var executionContext = TestExecutionContextHelper.CreateExecutionContext(new DirectoryInfo(rootDirectory));
var cacheRoot = Path.Combine(executionContext.CacheDirectory.FullName, "aspire-skills");
Directory.CreateDirectory(cacheRoot);
const string staleVersion = "9.9.9";
var staleArchiveSha512 = new string('a', 128);
var staleCacheDirectory = Path.Combine(cacheRoot, staleVersion, staleArchiveSha512);
await CreateCachedBundleAsync(staleCacheDirectory, archiveSha512: staleArchiveSha512);
await WriteLastUsedAsync(staleCacheDirectory, DateTimeOffset.UtcNow.AddDays(-1));
var staleVersionLockPath = Path.Combine(cacheRoot, $".{staleVersion}.lock");
var configuration = new ConfigurationBuilder()
.AddInMemoryCollection(new Dictionary<string, string?>
{
[AspireSkillsInstaller.MaxCacheAgeKey] = "60"
})
.Build();
var embeddedBundleProvider = await CreateEmbeddedBundleProviderAsync();
var features = new TestFeatures().SetFeature(KnownFeatures.AspireSkillsRemoteFetchEnabled, false);
var sink = new TestSink();
var logger = new TestLogger<AspireSkillsInstaller>(new TestLoggerFactory(sink, enabled: true));
var installer = CreateInstaller(
executionContext,
configuration: configuration,
embeddedBundleProvider: embeddedBundleProvider,
features: features,
logger: logger);
await using (var staleVersionLock = new FileStream(
staleVersionLockPath,
FileMode.OpenOrCreate,
FileAccess.ReadWrite,
FileShare.None,
bufferSize: 1,
FileOptions.Asynchronous))
{
var result = await installer.InstallAsync(CancellationToken.None).WaitAsync(TimeSpan.FromSeconds(10));
Assert.Equal(AspireSkillsInstallStatus.Installed, result.Status);
Assert.True(Directory.Exists(staleCacheDirectory));
Assert.Equal(3, sink.Writes.Count(context =>
context.Message?.Contains(CacheLockRetryLogMessage, StringComparison.Ordinal) == true));
Assert.Equal(1, sink.Writes.Count(context =>
context.Message?.Contains("Skipping cleanup", StringComparison.Ordinal) == true));
}
var cleanupResult = await installer.InstallAsync(CancellationToken.None);
Assert.Equal(AspireSkillsInstallStatus.Installed, cleanupResult.Status);
Assert.False(Directory.Exists(staleCacheDirectory));
}
finally
{
Directory.Delete(rootDirectory, recursive: true);
}
}
[Fact]
public async Task InstallAsync_RemovesOnlyStaleDigestCachesForCurrentVersion()
{
var rootDirectory = CreateTempDirectory();
try
{
var executionContext = TestExecutionContextHelper.CreateExecutionContext(new DirectoryInfo(rootDirectory));
var embeddedBundleProvider = await CreateEmbeddedBundleProviderAsync();
var currentCacheDirectory = GetBundleCacheDirectory(
executionContext,
embeddedBundleProvider.Metadata!.Sha512!);
await CreateCachedBundleAsync(
currentCacheDirectory,
archiveSha512: embeddedBundleProvider.Metadata.Sha512);
var staleArchiveSha512 = new string('a', 128);
var staleCacheDirectory = GetBundleCacheDirectory(executionContext, staleArchiveSha512);
await CreateCachedBundleAsync(staleCacheDirectory, archiveSha512: staleArchiveSha512);
await WriteLastUsedAsync(staleCacheDirectory, DateTimeOffset.UtcNow.AddDays(-1));
var recentArchiveSha512 = new string('b', 128);
var recentCacheDirectory = GetBundleCacheDirectory(executionContext, recentArchiveSha512);
await CreateCachedBundleAsync(recentCacheDirectory, archiveSha512: recentArchiveSha512);
await WriteLastUsedAsync(recentCacheDirectory, DateTimeOffset.UtcNow);
var configuration = new ConfigurationBuilder()
.AddInMemoryCollection(new Dictionary<string, string?>
{
[AspireSkillsInstaller.MaxCacheAgeKey] = "60"
})
.Build();
var features = new TestFeatures().SetFeature(KnownFeatures.AspireSkillsRemoteFetchEnabled, false);
var installer = CreateInstaller(
executionContext,
configuration: configuration,
embeddedBundleProvider: embeddedBundleProvider,
features: features);
var result = await installer.InstallAsync(CancellationToken.None);
Assert.Equal(AspireSkillsInstallStatus.Installed, result.Status);
Assert.True(Directory.Exists(currentCacheDirectory));
Assert.False(Directory.Exists(staleCacheDirectory));
Assert.True(Directory.Exists(recentCacheDirectory));
}
finally
{
Directory.Delete(rootDirectory, recursive: true);
}
}
[Fact]
public async Task InstallAsync_LeavesVersionLockFileAsStableSynchronizationAnchor()
{
var rootDirectory = CreateTempDirectory();
try
{
var executionContext = TestExecutionContextHelper.CreateExecutionContext(new DirectoryInfo(rootDirectory));
var embeddedBundleProvider = await CreateEmbeddedBundleProviderAsync();
var features = new TestFeatures().SetFeature(KnownFeatures.AspireSkillsRemoteFetchEnabled, false);
var installer = CreateInstaller(
executionContext,
embeddedBundleProvider: embeddedBundleProvider,
features: features);
var result = await installer.InstallAsync(CancellationToken.None);
Assert.Equal(AspireSkillsInstallStatus.Installed, result.Status);
Assert.True(File.Exists(Path.Combine(
executionContext.CacheDirectory.FullName,
"aspire-skills",
$".{AspireSkillsInstaller.Version}.lock")));
}
finally
{
Directory.Delete(rootDirectory, recursive: true);
}
}
[Fact]
public async Task InstallAsync_WhenCacheLockIsContendedAndCancellationWasRequested_ThrowsCancellation()
{
var rootDirectory = CreateTempDirectory();
try
{
var executionContext = TestExecutionContextHelper.CreateExecutionContext(new DirectoryInfo(rootDirectory));
var cacheRoot = Path.Combine(executionContext.CacheDirectory.FullName, "aspire-skills");
Directory.CreateDirectory(cacheRoot);
using var cacheLock = new FileStream(
Path.Combine(cacheRoot, $".{AspireSkillsInstaller.Version}.lock"),
FileMode.OpenOrCreate,
FileAccess.ReadWrite,
FileShare.None);
var embeddedBundleProvider = await CreateEmbeddedBundleProviderAsync();
var features = new TestFeatures().SetFeature(KnownFeatures.AspireSkillsRemoteFetchEnabled, false);
var retryObserved = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
var sink = new TestSink();
sink.MessageLogged += context =>
{
if (context.Message?.Contains(CacheLockRetryLogMessage, StringComparison.Ordinal) == true)
{
retryObserved.TrySetResult();
}
};
var logger = new TestLogger<AspireSkillsInstaller>(new TestLoggerFactory(sink, enabled: true));
var installer = CreateInstaller(
executionContext,
embeddedBundleProvider: embeddedBundleProvider,
features: features,
logger: logger);
using var cancellationTokenSource = new CancellationTokenSource();
var installTask = installer.InstallAsync(cancellationTokenSource.Token);
await retryObserved.Task.WaitAsync(TimeSpan.FromSeconds(10));
Assert.False(installTask.IsCompleted);
cancellationTokenSource.Cancel();
await Assert.ThrowsAnyAsync<OperationCanceledException>(
() => installTask);
}
finally
{
Directory.Delete(rootDirectory, recursive: true);
}
}
[Fact]
public async Task InstallAsync_RemovesOnlyStaleTemporaryCacheDirectories()
{
var rootDirectory = CreateTempDirectory();
try
{
var executionContext = TestExecutionContextHelper.CreateExecutionContext(new DirectoryInfo(rootDirectory));
var cacheRoot = Path.Combine(executionContext.CacheDirectory.FullName, "aspire-skills");
Directory.CreateDirectory(cacheRoot);
var temporaryDirectoryPrefixes = new[] { ".github-", ".embedded-", ".extract-", ".stage-" };
List<string> staleDirectories = [];
List<string> recentDirectories = [];
foreach (var prefix in temporaryDirectoryPrefixes)
{
var staleDirectory = Directory.CreateDirectory(Path.Combine(cacheRoot, $"{prefix}stale")).FullName;
Directory.SetLastWriteTimeUtc(staleDirectory, DateTime.UtcNow.AddDays(-8));
staleDirectories.Add(staleDirectory);
recentDirectories.Add(Directory.CreateDirectory(Path.Combine(cacheRoot, $"{prefix}recent")).FullName);
}
var embeddedBundleProvider = await CreateEmbeddedBundleProviderAsync();
var features = new TestFeatures().SetFeature(KnownFeatures.AspireSkillsRemoteFetchEnabled, false);
var installer = CreateInstaller(
executionContext,
embeddedBundleProvider: embeddedBundleProvider,
features: features);
var result = await installer.InstallAsync(CancellationToken.None);
Assert.Equal(AspireSkillsInstallStatus.Installed, result.Status);
Assert.All(staleDirectories, directory => Assert.False(Directory.Exists(directory)));
Assert.All(recentDirectories, directory => Assert.True(Directory.Exists(directory)));
}
finally
{
Directory.Delete(rootDirectory, recursive: true);
}
}
[Fact]
public async Task InstallAsync_WhenStaleTemporaryDirectoryIsActive_DoesNotDeleteIt()
{
var rootDirectory = CreateTempDirectory();
var verificationStarted = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
var verificationGate = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
Task<AspireSkillsInstallResult>? firstInstallTask = null;
try
{
var archiveBytes = await CreateBundleArchiveBytesAsync();
var handler = new MockHttpMessageHandler(request =>
{
if (request.RequestUri!.AbsolutePath.EndsWith("/releases/tags/v0.0.1", StringComparison.Ordinal))
{
return CreateJsonResponse(CreateGitHubReleaseJson(
"aspire-skills-v0.0.1.tgz",
"https://downloads.example.test/aspire-skills-v0.0.1.tgz",
$"sha256:{ComputeSha256(archiveBytes)}"));
}
return new HttpResponseMessage(HttpStatusCode.OK)
{
Content = new ByteArrayContent(archiveBytes)
};
});
var executionContext = TestExecutionContextHelper.CreateExecutionContext(new DirectoryInfo(rootDirectory));
var attestationVerifier = new TestGitHubArtifactAttestationVerifier
{
VerificationStarted = verificationStarted,
VerificationGate = verificationGate.Task
};
var firstInstaller = CreateInstaller(
executionContext,
httpMessageHandler: handler,
githubArtifactAttestationVerifier: attestationVerifier);
firstInstallTask = firstInstaller.InstallAsync(CancellationToken.None);
await verificationStarted.Task.WaitAsync(TimeSpan.FromSeconds(10));
var cacheRoot = Path.Combine(executionContext.CacheDirectory.FullName, "aspire-skills");
var activeTemporaryDirectory = Assert.Single(Directory.GetDirectories(cacheRoot, ".github-*"));
Directory.SetLastWriteTimeUtc(activeTemporaryDirectory, DateTime.UtcNow.AddDays(-8));
var embeddedBundleProvider = await CreateEmbeddedBundleProviderAsync();
var features = new TestFeatures().SetFeature(KnownFeatures.AspireSkillsRemoteFetchEnabled, false);
var secondInstaller = CreateInstaller(
executionContext,
embeddedBundleProvider: embeddedBundleProvider,
features: features);
var secondResult = await secondInstaller.InstallAsync(CancellationToken.None);
Assert.Equal(AspireSkillsInstallStatus.Installed, secondResult.Status);
Assert.True(Directory.Exists(activeTemporaryDirectory));
verificationGate.SetResult();
var firstResult = await firstInstallTask.WaitAsync(TimeSpan.FromSeconds(10));
Assert.Equal(AspireSkillsInstallStatus.Installed, firstResult.Status);
Assert.False(Directory.Exists(activeTemporaryDirectory));
Assert.False(File.Exists($"{activeTemporaryDirectory}.lock"));
}
finally
{
verificationGate.TrySetResult();
if (firstInstallTask is { IsCompleted: false })
{
await firstInstallTask.WaitAsync(TimeSpan.FromSeconds(10));
}
Directory.Delete(rootDirectory, recursive: true);
}
}
[Fact]
public async Task InstallAsync_WhenGitHubReleaseIsUnavailableAndNoCache_ReturnsFailure()
{
var rootDirectory = CreateTempDirectory();
try
{
var executionContext = TestExecutionContextHelper.CreateExecutionContext(new DirectoryInfo(rootDirectory));
var installer = CreateInstaller(executionContext);
var result = await installer.InstallAsync(CancellationToken.None);
Assert.Equal(AspireSkillsInstallStatus.Failed, result.Status);
Assert.Contains("GitHub", result.Message);
}
finally
{
Directory.Delete(rootDirectory, recursive: true);
}
}
[Fact]
public async Task InstallAsync_WhenGitHubReleaseIsUnavailableAndEmbeddedBundleMatches_UsesEmbeddedBundle()
{
var rootDirectory = CreateTempDirectory();
try
{
var executionContext = TestExecutionContextHelper.CreateExecutionContext(new DirectoryInfo(rootDirectory));
var embeddedBundleProvider = await CreateEmbeddedBundleProviderAsync();
var installer = CreateInstaller(executionContext, embeddedBundleProvider: embeddedBundleProvider);
var result = await installer.InstallAsync(CancellationToken.None);
Assert.Equal(AspireSkillsInstallStatus.Installed, result.Status);
Assert.NotNull(result.Bundle);
Assert.True(embeddedBundleProvider.CreateBundleCalled);
}
finally
{
Directory.Delete(rootDirectory, recursive: true);
}
}
[Fact]
public async Task InstallAsync_WhenGitHubResponseEndsEarly_UsesEmbeddedBundle()
{
var rootDirectory = CreateTempDirectory();
try
{
var executionContext = TestExecutionContextHelper.CreateExecutionContext(new DirectoryInfo(rootDirectory));
var embeddedBundleProvider = await CreateEmbeddedBundleProviderAsync();
var handler = new MockHttpMessageHandler(new HttpIOException(HttpRequestError.ResponseEnded));
var installer = CreateInstaller(
executionContext,
httpMessageHandler: handler,
embeddedBundleProvider: embeddedBundleProvider);
var result = await installer.InstallAsync(CancellationToken.None);
Assert.Equal(AspireSkillsInstallStatus.Installed, result.Status);
Assert.NotNull(result.Bundle);
Assert.True(embeddedBundleProvider.CreateBundleCalled);
}
finally
{
Directory.Delete(rootDirectory, recursive: true);
}
}
[Fact]
public async Task InstallAsync_WhenGitHubRequestTimesOut_UsesEmbeddedBundle()
{
var rootDirectory = CreateTempDirectory();
try
{
var executionContext = TestExecutionContextHelper.CreateExecutionContext(new DirectoryInfo(rootDirectory));
var embeddedBundleProvider = await CreateEmbeddedBundleProviderAsync();
var handler = new MockHttpMessageHandler(
new TaskCanceledException("The request timed out.", new TimeoutException()));
var installer = CreateInstaller(
executionContext,
httpMessageHandler: handler,
embeddedBundleProvider: embeddedBundleProvider);
var result = await installer.InstallAsync(CancellationToken.None);
Assert.Equal(AspireSkillsInstallStatus.Installed, result.Status);
Assert.NotNull(result.Bundle);
Assert.True(embeddedBundleProvider.CreateBundleCalled);
}
finally
{
Directory.Delete(rootDirectory, recursive: true);
}
}
[Fact]
public async Task InstallAsync_WhenGitHubRequestIsCanceledByCaller_ThrowsCancellation()
{
var rootDirectory = CreateTempDirectory();
try
{
using var cancellationTokenSource = new CancellationTokenSource();
var requestObserved = false;
var handler = new MockHttpMessageHandler(_ =>
{
requestObserved = true;
cancellationTokenSource.Cancel();
throw new OperationCanceledException(cancellationTokenSource.Token);
});
var executionContext = TestExecutionContextHelper.CreateExecutionContext(new DirectoryInfo(rootDirectory));
var installer = CreateInstaller(executionContext, httpMessageHandler: handler);
await Assert.ThrowsAnyAsync<OperationCanceledException>(
() => installer.InstallAsync(cancellationTokenSource.Token));
Assert.True(requestObserved);
}
finally
{
Directory.Delete(rootDirectory, recursive: true);
}
}
[Fact]
public async Task InstallAsync_WhenGitHubIsUnavailable_UsesVerifiedGitHubCache()
{
var rootDirectory = CreateTempDirectory();
try
{
var executionContext = TestExecutionContextHelper.CreateExecutionContext(new DirectoryInfo(rootDirectory));
var embeddedBundleProvider = await CreateEmbeddedBundleProviderAsync();
var verifiedArchiveSha512 = new string('a', 128);
var verifiedGitHubCacheDirectory = GetBundleCacheDirectory(executionContext, verifiedArchiveSha512);
await CreateCachedBundleAsync(
verifiedGitHubCacheDirectory,
archiveSha512: verifiedArchiveSha512,
githubAttestationVerified: true,
skillBody: "# Verified GitHub");
var installer = CreateInstaller(executionContext, embeddedBundleProvider: embeddedBundleProvider);
var result = await installer.InstallAsync(CancellationToken.None);
Assert.Equal(AspireSkillsInstallStatus.Installed, result.Status);
Assert.NotNull(result.Bundle);
Assert.False(embeddedBundleProvider.CreateBundleCalled);
var skill = Assert.Single(result.Bundle.GetSkillDefinitions());
var skillFile = Assert.Single(await result.Bundle.GetSkillFilesAsync(skill, CancellationToken.None));
Assert.Contains("# Verified GitHub", skillFile.Content, StringComparison.Ordinal);
}
finally
{
Directory.Delete(rootDirectory, recursive: true);
}
}
[Fact]
public async Task InstallAsync_WhenGitHubIsUnavailable_UsesMostRecentCompatibleVerifiedCache()
{
var rootDirectory = CreateTempDirectory();
try
{
var executionContext = TestExecutionContextHelper.CreateExecutionContext(new DirectoryInfo(rootDirectory));
var compatibleArchiveSha512 = new string('a', 128);
var compatibleCacheDirectory = GetBundleCacheDirectory(executionContext, compatibleArchiveSha512);
await CreateCachedBundleAsync(
compatibleCacheDirectory,
archiveSha512: compatibleArchiveSha512,
githubAttestationVerified: true,
skillBody: "# Compatible GitHub");
await WriteLastUsedAsync(compatibleCacheDirectory, DateTimeOffset.UtcNow.AddMinutes(-1));
var incompatibleArchiveSha512 = new string('b', 128);
var incompatibleCacheDirectory = GetBundleCacheDirectory(executionContext, incompatibleArchiveSha512);
await CreateCachedBundleAsync(
incompatibleCacheDirectory,
supports: new SkillBundleSupports
{
AspireCli = ">=99.0.0 <100.0.0",
AspireSdk = ">=99.0.0 <100.0.0"
},
archiveSha512: incompatibleArchiveSha512,
githubAttestationVerified: true,
skillBody: "# Incompatible GitHub");
await WriteLastUsedAsync(incompatibleCacheDirectory, DateTimeOffset.UtcNow);
var embeddedBundleProvider = await CreateEmbeddedBundleProviderAsync();
var installer = CreateInstaller(executionContext, embeddedBundleProvider: embeddedBundleProvider);
var result = await installer.InstallAsync(CancellationToken.None);
Assert.Equal(AspireSkillsInstallStatus.Installed, result.Status);
Assert.NotNull(result.Bundle);
Assert.False(embeddedBundleProvider.CreateBundleCalled);
var skill = Assert.Single(result.Bundle.GetSkillDefinitions());
var skillFile = Assert.Single(await result.Bundle.GetSkillFilesAsync(skill, CancellationToken.None));
Assert.Contains("# Compatible GitHub", skillFile.Content, StringComparison.Ordinal);
}
finally
{
Directory.Delete(rootDirectory, recursive: true);
}
}
[Fact]
public async Task InstallAsync_WhenGitHubDigestIdentifiesStaleCacheAndAssetIsUnavailable_UsesEmbeddedBundle()
{
var rootDirectory = CreateTempDirectory();
try
{
var currentArchiveBytes = await CreateBundleArchiveBytesAsync(skillBody: "# Current GitHub");
var currentArchiveSha256 = ComputeSha256(currentArchiveBytes);
var executionContext = TestExecutionContextHelper.CreateExecutionContext(new DirectoryInfo(rootDirectory));
var embeddedBundleProvider = await CreateEmbeddedBundleProviderAsync();
var staleArchiveSha256 = new string('a', 64);
var staleArchiveSha512 = new string('a', 128);
var verifiedGitHubCacheDirectory = GetBundleCacheDirectory(executionContext, staleArchiveSha512);
await CreateCachedBundleAsync(
verifiedGitHubCacheDirectory,
archiveSha512: staleArchiveSha512,
githubArchiveSha256: staleArchiveSha256,
githubAttestationVerified: true,
skillBody: "# Stale GitHub");
var assetDownloadRequested = false;
var handler = new MockHttpMessageHandler(request =>
{
if (request.RequestUri!.AbsolutePath.EndsWith("/releases/tags/v0.0.1", StringComparison.Ordinal))
{
return CreateJsonResponse(CreateGitHubReleaseJson(
"aspire-skills-v0.0.1.tgz",
"https://downloads.example.test/aspire-skills-v0.0.1.tgz",
$"sha256:{currentArchiveSha256}"));
}
assetDownloadRequested = true;
return new HttpResponseMessage(HttpStatusCode.ServiceUnavailable);
});
var installer = CreateInstaller(
executionContext,
httpMessageHandler: handler,
embeddedBundleProvider: embeddedBundleProvider);
var result = await installer.InstallAsync(CancellationToken.None);
Assert.Equal(AspireSkillsInstallStatus.Installed, result.Status);
Assert.NotNull(result.Bundle);
Assert.True(assetDownloadRequested);
Assert.True(embeddedBundleProvider.CreateBundleCalled);
var skill = Assert.Single(result.Bundle.GetSkillDefinitions());
var skillFile = Assert.Single(await result.Bundle.GetSkillFilesAsync(skill, CancellationToken.None));
Assert.Contains("# Aspire", skillFile.Content, StringComparison.Ordinal);
}
finally
{
Directory.Delete(rootDirectory, recursive: true);
}
}
[Theory]
[InlineData(null, false)]
[InlineData("not-a-sha256-digest", false)]
[InlineData(null, true)]
public async Task InstallAsync_WhenReleaseDigestCannotIdentifyUnavailableAsset_UsesEmbeddedBundle(
string? releaseDigest,
bool assetRequestTimesOut)
{
var rootDirectory = CreateTempDirectory();
try
{
var executionContext = TestExecutionContextHelper.CreateExecutionContext(new DirectoryInfo(rootDirectory));
var staleArchiveSha512 = new string('a', 128);
var verifiedGitHubCacheDirectory = GetBundleCacheDirectory(executionContext, staleArchiveSha512);
await CreateCachedBundleAsync(
verifiedGitHubCacheDirectory,
archiveSha512: staleArchiveSha512,
githubArchiveSha256: new string('a', 64),
githubAttestationVerified: true,
skillBody: "# Stale GitHub");
var assetDownloadRequested = false;
var handler = new MockHttpMessageHandler(request =>
{
if (request.RequestUri!.AbsolutePath.EndsWith("/releases/tags/v0.0.1", StringComparison.Ordinal))
{
return CreateJsonResponse(CreateGitHubReleaseJson(
"aspire-skills-v0.0.1.tgz",
"https://downloads.example.test/aspire-skills-v0.0.1.tgz",
releaseDigest));
}
assetDownloadRequested = true;
if (assetRequestTimesOut)
{
throw new TaskCanceledException("The request timed out.", new TimeoutException());
}
return new HttpResponseMessage(HttpStatusCode.ServiceUnavailable);
});
var embeddedBundleProvider = await CreateEmbeddedBundleProviderAsync();
var installer = CreateInstaller(
executionContext,
httpMessageHandler: handler,
embeddedBundleProvider: embeddedBundleProvider);
var result = await installer.InstallAsync(CancellationToken.None);
Assert.Equal(AspireSkillsInstallStatus.Installed, result.Status);
Assert.NotNull(result.Bundle);
Assert.True(assetDownloadRequested);
Assert.True(embeddedBundleProvider.CreateBundleCalled);
var skill = Assert.Single(result.Bundle.GetSkillDefinitions());
var skillFile = Assert.Single(await result.Bundle.GetSkillFilesAsync(skill, CancellationToken.None));
Assert.Contains("# Aspire", skillFile.Content, StringComparison.Ordinal);
}
finally
{
Directory.Delete(rootDirectory, recursive: true);
}
}
[Fact]
public async Task InstallAsync_WhenRemoteFetchFeatureIsDisabled_SkipsGitHubAndUsesEmbedded()
{
var rootDirectory = CreateTempDirectory();
try
{
var executionContext = TestExecutionContextHelper.CreateExecutionContext(new DirectoryInfo(rootDirectory));
var embeddedBundleProvider = await CreateEmbeddedBundleProviderAsync();
var attestationVerifier = new TestGitHubArtifactAttestationVerifier();
// Throw on any HTTP call so we can prove the GitHub path was never invoked.
var handler = new MockHttpMessageHandler(_ => throw new InvalidOperationException("HTTP must not be called when remote fetch is disabled."));
var features = new TestFeatures().SetFeature(KnownFeatures.AspireSkillsRemoteFetchEnabled, false);
var installer = CreateInstaller(
executionContext,
httpMessageHandler: handler,
githubArtifactAttestationVerifier: attestationVerifier,
embeddedBundleProvider: embeddedBundleProvider,
features: features);
var result = await installer.InstallAsync(CancellationToken.None);
Assert.Equal(AspireSkillsInstallStatus.Installed, result.Status);
Assert.NotNull(result.Bundle);
Assert.True(embeddedBundleProvider.CreateBundleCalled);
Assert.False(attestationVerifier.VerifyCalled);
}
finally
{
Directory.Delete(rootDirectory, recursive: true);
}
}
[Fact]
public async Task EmbeddedAspireSkillsBundleProvider_CreatesBundle()
{
var rootDirectory = CreateTempDirectory();
try
{
var bundleDirectory = new DirectoryInfo(Path.Combine(rootDirectory, "bundle"));
var provider = new EmbeddedAspireSkillsBundleProvider(
new AspireSkillsBundleProvider(),
NullLogger<EmbeddedAspireSkillsBundleProvider>.Instance);
var metadata = Assert.IsType<EmbeddedAspireSkillsBundleMetadata>(provider.Metadata);
var bundle = await provider.CreateBundleAsync(bundleDirectory, CancellationToken.None);
Assert.NotNull(bundle);
Assert.Equal(AspireSkillsInstaller.Version, bundle.Version);
Assert.Equal(AspireSkillsInstaller.Version, metadata.Version);
Assert.Equal(AspireSkillsInstaller.GitHubRepository, metadata.Repository);
Assert.Matches("^[0-9a-f]{128}$", metadata.Sha512);
AssertNoTemporaryEntries(rootDirectory, "embedded");
}
finally
{
Directory.Delete(rootDirectory, recursive: true);
}
}
[Fact]
public async Task EmbeddedAspireSkillsBundleProvider_WhenTemporaryArchiveIsLocked_CanPromoteBundle()
{
Assert.SkipUnless(OperatingSystem.IsWindows(), "This test validates Windows delete-sharing behavior.");
var rootDirectory = CreateTempDirectory();
ArchiveLockingAspireSkillsBundleProvider? lockingBundleProvider = null;
try
{
var stageDirectory = new DirectoryInfo(Path.Combine(rootDirectory, ".stage-test"));
var targetDirectory = Path.Combine(rootDirectory, "cached");
lockingBundleProvider = new ArchiveLockingAspireSkillsBundleProvider(new AspireSkillsBundleProvider());
var provider = new EmbeddedAspireSkillsBundleProvider(
lockingBundleProvider,
NullLogger<EmbeddedAspireSkillsBundleProvider>.Instance);
var bundle = await provider.CreateBundleAsync(stageDirectory, CancellationToken.None);
Assert.NotNull(bundle);
Assert.NotEmpty(Directory.GetDirectories(rootDirectory, ".embedded-*"));
Directory.Move(stageDirectory.FullName, targetDirectory);
Assert.True(File.Exists(Path.Combine(targetDirectory, "skill-manifest.json")));
}
finally
{
lockingBundleProvider?.Dispose();
Directory.Delete(rootDirectory, recursive: true);
}
}
[Fact]
public async Task AspireSkillsBundleProvider_WhenValidationFails_RemovesExtractionDirectory()
{
var rootDirectory = CreateTempDirectory();
try
{
var archivePath = Path.Combine(rootDirectory, "bundle.tgz");
var archiveBytes = await CreateBundleArchiveBytesAsync(
new SkillBundleSupports { AspireCli = ">=99.0.0 <100.0.0" });
await File.WriteAllBytesAsync(archivePath, archiveBytes);
var bundleDirectory = new DirectoryInfo(Path.Combine(rootDirectory, "bundle"));
var provider = new AspireSkillsBundleProvider("13.4.0", "13.4.0");
var exception = await Assert.ThrowsAsync<InvalidOperationException>(() => provider.CreateAsync(
new FileInfo(archivePath),
bundleDirectory,
ComputeSha512(archiveBytes),
CancellationToken.None));
Assert.Contains("supports Aspire CLI versions", exception.Message);
AssertNoTemporaryEntries(rootDirectory, "extract");
}
finally
{
Directory.Delete(rootDirectory, recursive: true);
}
}
[Fact]
public async Task AspireSkillsBundleProvider_DoesNotUseExistingDestinationFilesToValidateArchive()
{
var rootDirectory = CreateTempDirectory();
try
{
var archivePath = Path.Combine(rootDirectory, "bundle.tgz");
var archiveBytes = await CreateBundleArchiveBytesAsync(includeSkillFile: false);
await File.WriteAllBytesAsync(archivePath, archiveBytes);
var bundleDirectory = Path.Combine(rootDirectory, "bundle");
await CreateCachedBundleAsync(bundleDirectory);
var provider = new AspireSkillsBundleProvider();
var exception = await Assert.ThrowsAsync<InvalidOperationException>(() => provider.CreateAsync(
new FileInfo(archivePath),
new DirectoryInfo(bundleDirectory),
ComputeSha512(archiveBytes),
CancellationToken.None));
Assert.Contains("was not found", exception.Message);
}
finally
{
Directory.Delete(rootDirectory, recursive: true);
}
}
[Fact]
public async Task EmbeddedAspireSkillsBundleProvider_WhenCancelled_RemovesTemporaryArchive()
{
var rootDirectory = CreateTempDirectory();
try
{
var bundleDirectory = new DirectoryInfo(Path.Combine(rootDirectory, "bundle"));
var provider = new EmbeddedAspireSkillsBundleProvider(
new AspireSkillsBundleProvider(),
NullLogger<EmbeddedAspireSkillsBundleProvider>.Instance);
using var cancellationTokenSource = new CancellationTokenSource();
cancellationTokenSource.Cancel();
await Assert.ThrowsAnyAsync<OperationCanceledException>(() => provider.CreateBundleAsync(
bundleDirectory,
cancellationTokenSource.Token));
AssertNoTemporaryEntries(rootDirectory, "embedded");
}
finally
{
Directory.Delete(rootDirectory, recursive: true);
}
}
[Fact]
public async Task InstallAsync_WhenGitHubReleaseDigestIsMissing_DownloadsAndCachesComputedDigests()
{
var rootDirectory = CreateTempDirectory();
try
{
var archiveBytes = await CreateBundleArchiveBytesAsync();
var archiveSha256 = ComputeSha256(archiveBytes);
var archiveSha512 = ComputeSha512(archiveBytes);
Uri? releaseRequestUri = null;
Uri? assetRequestUri = null;
var handler = new MockHttpMessageHandler(request =>
{
if (request.RequestUri!.AbsolutePath.EndsWith("/releases/tags/v0.0.1", StringComparison.Ordinal))
{
releaseRequestUri = request.RequestUri;
return CreateJsonResponse(CreateGitHubReleaseJson(
"aspire-skills-v0.0.1.tgz",
"https://downloads.example.test/aspire-skills-v0.0.1.tgz"));
}
assetRequestUri = request.RequestUri;
return new HttpResponseMessage(HttpStatusCode.OK)
{
Content = new ByteArrayContent(archiveBytes)
};
});
var executionContext = TestExecutionContextHelper.CreateExecutionContext(new DirectoryInfo(rootDirectory));
var attestationVerifier = new TestGitHubArtifactAttestationVerifier();
var embeddedBundleProvider = await CreateEmbeddedBundleProviderAsync();
var installer = CreateInstaller(
executionContext,
httpMessageHandler: handler,
githubArtifactAttestationVerifier: attestationVerifier,
embeddedBundleProvider: embeddedBundleProvider);
var result = await installer.InstallAsync(CancellationToken.None);
Assert.Equal(AspireSkillsInstallStatus.Installed, result.Status);
Assert.NotNull(result.Bundle);
Assert.True(attestationVerifier.VerifyCalled);
Assert.Equal(AspireSkillsInstaller.GitHubRepository, attestationVerifier.Repository);
Assert.Equal(AspireSkillsInstaller.ExpectedSourceRepository, attestationVerifier.ExpectedSourceRepository);
Assert.Equal(AspireSkillsInstaller.ExpectedWorkflowPath, attestationVerifier.ExpectedWorkflowPath);
Assert.Equal(GitHubReleaseAssetBuildType, attestationVerifier.ExpectedBuildType);
Assert.Equal(AspireSkillsInstaller.Version, attestationVerifier.ExpectedVersion);
Assert.False(embeddedBundleProvider.CreateBundleCalled);
Assert.NotNull(releaseRequestUri);
Assert.NotNull(assetRequestUri);
Assert.Contains("/microsoft/aspire-skills/releases/tags/v0.0.1", releaseRequestUri.AbsolutePath);
Assert.Equal("https://downloads.example.test/aspire-skills-v0.0.1.tgz", assetRequestUri.AbsoluteUri);
var cachedBundleDirectory = GetBundleCacheDirectory(executionContext, archiveSha512);
Assert.Equal(
archiveSha512,
await File.ReadAllTextAsync(Path.Combine(
cachedBundleDirectory,
AspireSkillsInstaller.ArchiveSha512FileName)));
Assert.Equal(
archiveSha256,
await File.ReadAllTextAsync(Path.Combine(
cachedBundleDirectory,
AspireSkillsInstaller.GitHubArchiveSha256FileName)));
}
finally
{
Directory.Delete(rootDirectory, recursive: true);
}
}
[Fact]
public async Task InstallAsync_WhenGitHubReleaseDigestDoesNotMatchDownload_UsesEmbeddedBundle()
{
var rootDirectory = CreateTempDirectory();
try
{
var archiveBytes = await CreateBundleArchiveBytesAsync(skillBody: "# Downloaded GitHub");
var handler = new MockHttpMessageHandler(request =>
{
if (request.RequestUri!.AbsolutePath.EndsWith("/releases/tags/v0.0.1", StringComparison.Ordinal))
{
return CreateJsonResponse(CreateGitHubReleaseJson(
"aspire-skills-v0.0.1.tgz",
"https://downloads.example.test/aspire-skills-v0.0.1.tgz",
$"sha256:{new string('0', 64)}"));
}
return new HttpResponseMessage(HttpStatusCode.OK)
{
Content = new ByteArrayContent(archiveBytes)
};
});
var executionContext = TestExecutionContextHelper.CreateExecutionContext(new DirectoryInfo(rootDirectory));
var attestationVerifier = new TestGitHubArtifactAttestationVerifier();
var embeddedBundleProvider = await CreateEmbeddedBundleProviderAsync();
var installer = CreateInstaller(
executionContext,
httpMessageHandler: handler,
githubArtifactAttestationVerifier: attestationVerifier,
embeddedBundleProvider: embeddedBundleProvider);
var result = await installer.InstallAsync(CancellationToken.None);
Assert.Equal(AspireSkillsInstallStatus.Installed, result.Status);
Assert.NotNull(result.Bundle);
Assert.False(attestationVerifier.VerifyCalled);
Assert.True(embeddedBundleProvider.CreateBundleCalled);
var skill = Assert.Single(result.Bundle.GetSkillDefinitions());
var skillFile = Assert.Single(await result.Bundle.GetSkillFilesAsync(skill, CancellationToken.None));
Assert.Contains("# Aspire", skillFile.Content, StringComparison.Ordinal);
}
finally
{
Directory.Delete(rootDirectory, recursive: true);
}
}
[Fact]
public async Task InstallAsync_WhenDownloadedManifestIsMalformed_DoesNotUseVerifiedOfflineCache()
{
var rootDirectory = CreateTempDirectory();
try
{
var malformedArchiveBytes = await CreateBundleArchiveBytesAsync(malformedManifest: true);
var malformedArchiveSha256 = ComputeSha256(malformedArchiveBytes);
var handler = new MockHttpMessageHandler(request =>
{
if (request.RequestUri!.AbsolutePath.EndsWith("/releases/tags/v0.0.1", StringComparison.Ordinal))
{
return CreateJsonResponse(CreateGitHubReleaseJson(
"aspire-skills-v0.0.1.tgz",
"https://downloads.example.test/aspire-skills-v0.0.1.tgz",
$"sha256:{malformedArchiveSha256}"));
}
return new HttpResponseMessage(HttpStatusCode.OK)
{
Content = new ByteArrayContent(malformedArchiveBytes)
};
});
var executionContext = TestExecutionContextHelper.CreateExecutionContext(new DirectoryInfo(rootDirectory));
var verifiedArchiveSha512 = new string('a', 128);
await CreateCachedBundleAsync(
GetBundleCacheDirectory(executionContext, verifiedArchiveSha512),
archiveSha512: verifiedArchiveSha512,
githubAttestationVerified: true,
skillBody: "# Verified Offline");
var attestationVerifier = new TestGitHubArtifactAttestationVerifier();
var embeddedBundleProvider = await CreateEmbeddedBundleProviderAsync();
var installer = CreateInstaller(
executionContext,
httpMessageHandler: handler,
githubArtifactAttestationVerifier: attestationVerifier,
embeddedBundleProvider: embeddedBundleProvider);
var result = await installer.InstallAsync(CancellationToken.None);
Assert.Equal(AspireSkillsInstallStatus.Installed, result.Status);
Assert.NotNull(result.Bundle);
Assert.True(attestationVerifier.VerifyCalled);
Assert.True(embeddedBundleProvider.CreateBundleCalled);
var skill = Assert.Single(result.Bundle.GetSkillDefinitions());
var skillFile = Assert.Single(await result.Bundle.GetSkillFilesAsync(skill, CancellationToken.None));
Assert.Contains("# Aspire", skillFile.Content, StringComparison.Ordinal);
}
finally
{
Directory.Delete(rootDirectory, recursive: true);
}
}
[Fact]
public async Task InstallAsync_WhenGitHubAttestationFails_FallsBackToEmbeddedBundle()
{
var rootDirectory = CreateTempDirectory();
try
{
var archiveBytes = await CreateBundleArchiveBytesAsync(skillBody: "# Downloaded GitHub");
var archiveSha256 = ComputeSha256(archiveBytes);
var archiveSha512 = ComputeSha512(archiveBytes);
var handler = new MockHttpMessageHandler(request =>
{
if (request.RequestUri!.AbsolutePath.EndsWith("/releases/tags/v0.0.1", StringComparison.Ordinal))
{
return CreateJsonResponse(CreateGitHubReleaseJson(
"aspire-skills-v0.0.1.tgz",
"https://downloads.example.test/aspire-skills-v0.0.1.tgz",
$"sha256:{archiveSha256}"));
}
return new HttpResponseMessage(HttpStatusCode.OK)
{
Content = new ByteArrayContent(archiveBytes)
};
});
var executionContext = TestExecutionContextHelper.CreateExecutionContext(new DirectoryInfo(rootDirectory));
var cachedGitHubDirectory = GetBundleCacheDirectory(executionContext, archiveSha512);
await CreateCachedBundleAsync(
cachedGitHubDirectory,
archiveSha512: archiveSha512,
githubArchiveSha256: archiveSha256,
skillBody: "# Cached GitHub");
var attestationVerifier = new TestGitHubArtifactAttestationVerifier
{
Result = new ProvenanceVerificationResult { Outcome = ProvenanceVerificationOutcome.WorkflowMismatch }
};
var embeddedBundleProvider = await CreateEmbeddedBundleProviderAsync();
var installer = CreateInstaller(
executionContext,
httpMessageHandler: handler,
githubArtifactAttestationVerifier: attestationVerifier,
embeddedBundleProvider: embeddedBundleProvider);
var result = await installer.InstallAsync(CancellationToken.None);
Assert.Equal(AspireSkillsInstallStatus.Installed, result.Status);
Assert.NotNull(result.Bundle);
Assert.True(attestationVerifier.VerifyCalled);
Assert.True(embeddedBundleProvider.CreateBundleCalled);
Assert.False(File.Exists(Path.Combine(
cachedGitHubDirectory,
AspireSkillsInstaller.GitHubAttestationVerifiedFileName)));
var skill = Assert.Single(result.Bundle.GetSkillDefinitions());
var skillFile = Assert.Single(await result.Bundle.GetSkillFilesAsync(skill, CancellationToken.None));
Assert.Contains("# Aspire", skillFile.Content, StringComparison.Ordinal);
}
finally
{
Directory.Delete(rootDirectory, recursive: true);
}
}
[Fact]
public async Task InstallAsync_WhenVersionOverrideDoesNotMatchEmbeddedBundle_DoesNotUseEmbeddedBundle()
{
var rootDirectory = CreateTempDirectory();
try
{
var configuration = new ConfigurationBuilder()
.AddInMemoryCollection(new Dictionary<string, string?>
{
[AspireSkillsInstaller.VersionOverrideKey] = "9.9.9"
})
.Build();
var executionContext = TestExecutionContextHelper.CreateExecutionContext(new DirectoryInfo(rootDirectory));
var embeddedBundleProvider = await CreateEmbeddedBundleProviderAsync();
var installer = CreateInstaller(
executionContext,
configuration: configuration,
embeddedBundleProvider: embeddedBundleProvider);
var result = await installer.InstallAsync(CancellationToken.None);
Assert.Equal(AspireSkillsInstallStatus.Failed, result.Status);
Assert.False(embeddedBundleProvider.CreateBundleCalled);
}
finally
{
Directory.Delete(rootDirectory, recursive: true);
}
}
[Fact]
public async Task InstallAsync_WhenEmbeddedArchiveHashDoesNotMatch_ReturnsFailure()
{
var rootDirectory = CreateTempDirectory();
try
{
var embeddedBundleProvider = await CreateEmbeddedBundleProviderAsync();
embeddedBundleProvider.Metadata = new EmbeddedAspireSkillsBundleMetadata
{
Version = AspireSkillsInstaller.Version,
Repository = AspireSkillsInstaller.GitHubRepository,
Tag = $"v{AspireSkillsInstaller.Version}",
AssetName = $"aspire-skills-v{AspireSkillsInstaller.Version}.tgz",
Sha512 = new string('0', 128)
};
var executionContext = TestExecutionContextHelper.CreateExecutionContext(new DirectoryInfo(rootDirectory));
var installer = CreateInstaller(
executionContext,
embeddedBundleProvider: embeddedBundleProvider);
var result = await installer.InstallAsync(CancellationToken.None);
Assert.Equal(AspireSkillsInstallStatus.Failed, result.Status);
Assert.NotNull(result.Message);
Assert.Contains("SHA-512", result.Message, StringComparison.Ordinal);
Assert.Contains(new string('0', 128), result.Message, StringComparison.Ordinal);
Assert.True(embeddedBundleProvider.CreateBundleCalled);
}
finally
{
Directory.Delete(rootDirectory, recursive: true);
}
}
[Fact]
public async Task InstallAsync_WhenEmbeddedBundleSupportsRangeExcludesCurrentCli_StillUsesEmbeddedBundle()
{
var rootDirectory = CreateTempDirectory();
try
{
// Simulate a CLI prerelease whose version falls outside the embedded snapshot's
// declared `supports` range (e.g., a 13.5.x dogfood build paired with a snapshot
// stamped ">=13.4.0 <13.5.0"). The embedded path must still install the bundle —
// otherwise an offline user with a version-mismatched embedded snapshot would lose
// access to all bundled skills.
var staleSupports = new SkillBundleSupports
{
AspireCli = ">=0.0.1 <0.0.2",
AspireSdk = ">=0.0.1 <0.0.2"
};
var executionContext = TestExecutionContextHelper.CreateExecutionContext(new DirectoryInfo(rootDirectory));
var embeddedBundleProvider = await CreateEmbeddedBundleProviderAsync(supports: staleSupports);
var installer = CreateInstaller(executionContext, embeddedBundleProvider: embeddedBundleProvider);
var result = await installer.InstallAsync(CancellationToken.None);
Assert.Equal(AspireSkillsInstallStatus.Installed, result.Status);
Assert.NotNull(result.Bundle);
Assert.True(embeddedBundleProvider.CreateBundleCalled);
}
finally
{
Directory.Delete(rootDirectory, recursive: true);
}
}
[Fact]
public async Task InstallAsync_WhenCachedEmbeddedBundleSupportsRangeExcludesCurrentCli_StillUsesCache()
{
var rootDirectory = CreateTempDirectory();
try
{
var executionContext = TestExecutionContextHelper.CreateExecutionContext(new DirectoryInfo(rootDirectory));
// The cache is written by the installer itself (either from GitHub or from the
// embedded snapshot), so the bundle's `supports` range is not an invalidation
// signal. A stale `supports` on a cached entry with the expected version and
// archive digest must not force a re-install on every invocation.
var embeddedBundleProvider = await CreateEmbeddedBundleProviderAsync();
var cachedBundleDirectory = GetBundleCacheDirectory(executionContext, embeddedBundleProvider.Metadata!.Sha512!);
await CreateCachedBundleAsync(
cachedBundleDirectory,
supports: new SkillBundleSupports
{
AspireCli = ">=0.0.1 <0.0.2",
AspireSdk = ">=0.0.1 <0.0.2"
},
archiveSha512: embeddedBundleProvider.Metadata!.Sha512);
var installer = CreateInstaller(executionContext, embeddedBundleProvider: embeddedBundleProvider);
var result = await installer.InstallAsync(CancellationToken.None);
Assert.Equal(AspireSkillsInstallStatus.Installed, result.Status);
Assert.NotNull(result.Bundle);
Assert.False(embeddedBundleProvider.CreateBundleCalled);
}
finally
{
Directory.Delete(rootDirectory, recursive: true);
}
}
[Fact]
public async Task InstallAsync_WhenCachedGitHubBundleSupportsRangeExcludesCurrentCli_UsesEmbeddedBundle()
{
var rootDirectory = CreateTempDirectory();
try
{
var archiveSha256 = new string('a', 64);
var archiveSha512 = new string('a', 128);
var executionContext = TestExecutionContextHelper.CreateExecutionContext(new DirectoryInfo(rootDirectory));
var cachedBundleDirectory = GetBundleCacheDirectory(executionContext, archiveSha512);
await CreateCachedBundleAsync(
cachedBundleDirectory,
supports: new SkillBundleSupports
{
AspireCli = ">=99.0.0 <100.0.0",
AspireSdk = ">=99.0.0 <100.0.0"
},
archiveSha512: archiveSha512,
githubArchiveSha256: archiveSha256,
githubAttestationVerified: true);
var assetDownloadRequested = false;
var handler = new MockHttpMessageHandler(request =>
{
if (request.RequestUri!.AbsolutePath.EndsWith("/releases/tags/v0.0.1", StringComparison.Ordinal))
{
return CreateJsonResponse(CreateGitHubReleaseJson(
"aspire-skills-v0.0.1.tgz",
"https://downloads.example.test/aspire-skills-v0.0.1.tgz",
$"sha256:{archiveSha256}"));
}
assetDownloadRequested = true;
return new HttpResponseMessage(HttpStatusCode.ServiceUnavailable);
});
var embeddedBundleProvider = await CreateEmbeddedBundleProviderAsync();
var installer = CreateInstaller(
executionContext,
httpMessageHandler: handler,
embeddedBundleProvider: embeddedBundleProvider);
var result = await installer.InstallAsync(CancellationToken.None);
Assert.Equal(AspireSkillsInstallStatus.Installed, result.Status);
Assert.NotNull(result.Bundle);
Assert.True(assetDownloadRequested);
Assert.True(embeddedBundleProvider.CreateBundleCalled);
}
finally
{
Directory.Delete(rootDirectory, recursive: true);
}
}
private static AspireSkillsInstaller CreateInstaller(
CliExecutionContext executionContext,
HttpMessageHandler? httpMessageHandler = null,
TestGitHubArtifactAttestationVerifier? githubArtifactAttestationVerifier = null,
IConfiguration? configuration = null,
IEmbeddedAspireSkillsBundleProvider? embeddedBundleProvider = null,
IFeatures? features = null,
ILogger<AspireSkillsInstaller>? logger = null)
{
return new AspireSkillsInstaller(
githubArtifactAttestationVerifier ?? new TestGitHubArtifactAttestationVerifier(),
new MockHttpClientFactory(httpMessageHandler ?? new MockHttpMessageHandler(_ => new HttpResponseMessage(HttpStatusCode.NotFound))),
new AspireSkillsBundleProvider(executionContext),
embeddedBundleProvider ?? new TestEmbeddedAspireSkillsBundleProvider(),
new TestInteractionService(),
executionContext,
configuration ?? new ConfigurationBuilder().Build(),
// Default existing tests to the remote-fetch-enabled path so they continue to
// exercise the GitHub flow without per-test boilerplate. Tests that want to
// exercise the production default (flag off) pass an empty TestFeatures.
features ?? new TestFeatures().SetFeature(KnownFeatures.AspireSkillsRemoteFetchEnabled, true),
TestTelemetryHelper.CreateInitializedTelemetry(),
logger ?? NullLogger<AspireSkillsInstaller>.Instance);
}
private static async Task CreateCachedBundleAsync(
string bundleDirectory,
SkillBundleSupports? supports = null,
string? archiveSha512 = null,
string? githubArchiveSha256 = null,
bool githubAttestationVerified = false,
string skillBody = "# Aspire")
{
var skillDirectory = Path.Combine(bundleDirectory, "skills", CommonAgentApplicators.AspireSkillName);
Directory.CreateDirectory(skillDirectory);
var skillPath = Path.Combine(skillDirectory, "SKILL.md");
await File.WriteAllTextAsync(skillPath,
$$"""
---
name: aspire
description: "Aspire CLI commands and workflows for distributed apps"
---
{{skillBody}}
""");
var manifest = new SkillBundleManifest
{
Version = AspireSkillsInstaller.Version,
Supports = supports ?? CreateSupports(),
Skills =
[
new SkillBundleSkill
{
Name = CommonAgentApplicators.AspireSkillName,
Description = AspireSkillDescription,
Files =
[
new SkillBundleFile
{
RelativePath = "SKILL.md",
Sha512 = ComputeSha512(skillPath)
}
]
}
]
};
var manifestJson = JsonSerializer.Serialize(manifest, AspireSkillsJsonSerializerContext.Default.SkillBundleManifest);
await File.WriteAllTextAsync(Path.Combine(bundleDirectory, "skill-manifest.json"), manifestJson);
if (archiveSha512 is not null)
{
await File.WriteAllTextAsync(Path.Combine(bundleDirectory, AspireSkillsInstaller.ArchiveSha512FileName), archiveSha512);
}
if (githubArchiveSha256 is not null)
{
await File.WriteAllTextAsync(
Path.Combine(bundleDirectory, AspireSkillsInstaller.GitHubArchiveSha256FileName),
githubArchiveSha256);
}
if (githubAttestationVerified)
{
await File.WriteAllTextAsync(
Path.Combine(bundleDirectory, AspireSkillsInstaller.GitHubAttestationVerifiedFileName),
string.Empty);
}
}
private static Task WriteLastUsedAsync(string cacheDirectory, DateTimeOffset lastUsed)
{
return File.WriteAllTextAsync(
Path.Combine(cacheDirectory, ".lastused"),
lastUsed.ToUnixTimeSeconds().ToString(System.Globalization.CultureInfo.InvariantCulture));
}
private static SkillBundleSupports CreateSupports()
{
return new SkillBundleSupports
{
AspireCli = ">=0.0.0 <999.0.0",
AspireSdk = ">=0.0.0 <999.0.0"
};
}
private static async Task<byte[]> CreateBundleArchiveBytesAsync(
SkillBundleSupports? supports = null,
string skillBody = "# Aspire",
bool includeGitHubAttestationMarker = false,
bool malformedManifest = false,
bool includeSkillFile = true)
{
var rootDirectory = CreateTempDirectory();
try
{
var bundleDirectory = Path.Combine(rootDirectory, $"aspire-skills-v{AspireSkillsInstaller.Version}");
await CreateCachedBundleAsync(bundleDirectory, supports, skillBody: skillBody);
if (includeGitHubAttestationMarker)
{
await File.WriteAllTextAsync(
Path.Combine(bundleDirectory, AspireSkillsInstaller.GitHubAttestationVerifiedFileName),
string.Empty);
}
if (malformedManifest)
{
await File.WriteAllTextAsync(Path.Combine(bundleDirectory, "skill-manifest.json"), "{");
}
if (!includeSkillFile)
{
File.Delete(Path.Combine(bundleDirectory, "skills", CommonAgentApplicators.AspireSkillName, "SKILL.md"));
}
await using var archiveStream = new MemoryStream();
await using (var gzipStream = new GZipStream(archiveStream, CompressionLevel.SmallestSize, leaveOpen: true))
{
TarFile.CreateFromDirectory(bundleDirectory, gzipStream, includeBaseDirectory: true);
}
return archiveStream.ToArray();
}
finally
{
Directory.Delete(rootDirectory, recursive: true);
}
}
private static string ComputeSha256(byte[] bytes)
{
return Convert.ToHexString(SHA256.HashData(bytes)).ToLowerInvariant();
}
private static string ComputeSha512(string path)
{
using var stream = File.OpenRead(path);
return Convert.ToHexString(SHA512.HashData(stream)).ToLowerInvariant();
}
private static string ComputeSha512(byte[] bytes)
{
return Convert.ToHexString(SHA512.HashData(bytes)).ToLowerInvariant();
}
private static string GetVersionCacheDirectory(CliExecutionContext executionContext)
{
return Path.Combine(
executionContext.CacheDirectory.FullName,
"aspire-skills",
AspireSkillsInstaller.Version);
}
private static string GetBundleCacheDirectory(CliExecutionContext executionContext, string archiveSha512)
{
return Path.Combine(
GetVersionCacheDirectory(executionContext),
AspireSkillsBundleProvider.NormalizeSha512(archiveSha512));
}
private static async Task<TestEmbeddedAspireSkillsBundleProvider> CreateEmbeddedBundleProviderAsync(SkillBundleSupports? supports = null)
{
var archiveBytes = await CreateBundleArchiveBytesAsync(supports);
return new TestEmbeddedAspireSkillsBundleProvider
{
Metadata = new EmbeddedAspireSkillsBundleMetadata
{
Version = AspireSkillsInstaller.Version,
Repository = AspireSkillsInstaller.GitHubRepository,
Tag = $"v{AspireSkillsInstaller.Version}",
AssetName = $"aspire-skills-v{AspireSkillsInstaller.Version}.tgz",
Sha512 = ComputeSha512(archiveBytes)
},
ArchiveBytes = archiveBytes
};
}
private static HttpResponseMessage CreateJsonResponse(string json)
{
return new HttpResponseMessage(HttpStatusCode.OK)
{
Content = new StringContent(json)
};
}
private static string CreateGitHubReleaseJson(string assetName, string downloadUrl, string? digest = null)
{
return JsonSerializer.Serialize(new
{
tag_name = $"v{AspireSkillsInstaller.Version}",
assets = new[]
{
new
{
name = assetName,
browser_download_url = downloadUrl,
digest
}
}
});
}
private static string CreateTempDirectory()
{
return Directory.CreateTempSubdirectory("aspire-skills-installer-test-").FullName;
}
private static void AssertNoTemporaryEntries(string rootDirectory, string prefix)
{
Assert.Empty(Directory.GetDirectories(rootDirectory, $".{prefix}-*"));
Assert.Empty(Directory.GetFiles(rootDirectory, $".{prefix}-*.lock"));
}
private sealed class TestGitHubArtifactAttestationVerifier : IGitHubArtifactAttestationVerifier
{
public bool VerifyCalled { get; private set; }
public string? Repository { get; private set; }
public string? ExpectedSourceRepository { get; private set; }
public string? ExpectedWorkflowPath { get; private set; }
public string? ExpectedBuildType { get; private set; }
public string? ExpectedVersion { get; private set; }
public ProvenanceVerificationResult Result { get; init; } = new()
{
Outcome = ProvenanceVerificationOutcome.Verified,
Provenance = new NpmProvenanceData { SourceRepository = AspireSkillsInstaller.ExpectedSourceRepository }
};
public TaskCompletionSource? VerificationStarted { get; init; }
public Task? VerificationGate { get; init; }
public async Task<ProvenanceVerificationResult> VerifyAsync(
string repository,
string artifactPath,
string expectedSourceRepository,
string expectedWorkflowPath,
string expectedBuildType,
string expectedVersion,
CancellationToken cancellationToken)
{
VerifyCalled = true;
Repository = repository;
ExpectedSourceRepository = expectedSourceRepository;
ExpectedWorkflowPath = expectedWorkflowPath;
ExpectedBuildType = expectedBuildType;
ExpectedVersion = expectedVersion;
VerificationStarted?.TrySetResult();
if (VerificationGate is not null)
{
await VerificationGate.WaitAsync(cancellationToken);
}
return Result;
}
}
private sealed class TestEmbeddedAspireSkillsBundleProvider : IEmbeddedAspireSkillsBundleProvider
{
private readonly IAspireSkillsBundleProvider _bundleProvider = new AspireSkillsBundleProvider();
public EmbeddedAspireSkillsBundleMetadata? Metadata { get; set; }
public byte[]? ArchiveBytes { get; init; }
public bool CreateBundleCalled { get; private set; }
public async Task<AspireSkillsBundle?> CreateBundleAsync(
DirectoryInfo bundleDirectory,
CancellationToken cancellationToken)
{
CreateBundleCalled = true;
if (ArchiveBytes is null || string.IsNullOrWhiteSpace(Metadata?.Sha512))
{
return null;
}
Directory.CreateDirectory(bundleDirectory.FullName);
var archivePath = Path.Combine(bundleDirectory.FullName, $".test-embedded-{Guid.NewGuid():N}.tgz");
try
{
await File.WriteAllBytesAsync(archivePath, ArchiveBytes, cancellationToken);
return await _bundleProvider.CreateAsync(
new FileInfo(archivePath),
bundleDirectory,
Metadata.Sha512,
cancellationToken,
skipCompatibilityCheck: true);
}
finally
{
File.Delete(archivePath);
}
}
}
private sealed class ArchiveLockingAspireSkillsBundleProvider(IAspireSkillsBundleProvider inner) : IAspireSkillsBundleProvider, IDisposable
{
private FileStream? _archiveLock;
public async Task<AspireSkillsBundle> CreateAsync(
FileInfo archive,
DirectoryInfo bundleDirectory,
string expectedArchiveSha512,
CancellationToken cancellationToken,
bool skipCompatibilityCheck = false)
{
_archiveLock = new FileStream(
archive.FullName,
FileMode.Open,
FileAccess.Read,
FileShare.Read);
return await inner.CreateAsync(
archive,
bundleDirectory,
expectedArchiveSha512,
cancellationToken,
skipCompatibilityCheck);
}
public Task<AspireSkillsBundle> LoadAsync(
DirectoryInfo bundleDirectory,
CancellationToken cancellationToken,
bool skipCompatibilityCheck = false)
{
return inner.LoadAsync(bundleDirectory, cancellationToken, skipCompatibilityCheck);
}
public void Dispose()
{
_archiveLock?.Dispose();
}
}
}