Initial commit (history squashed)
	
		
			
	
		
	
	
		
	
		
			Some checks failed
		
		
	
	
		
			
				
	
				Build Test Deploy / authority-container (push) Has been cancelled
				
			
		
			
				
	
				Build Test Deploy / docs (push) Has been cancelled
				
			
		
			
				
	
				Build Test Deploy / deploy (push) Has been cancelled
				
			
		
			
				
	
				Build Test Deploy / build-test (push) Has been cancelled
				
			
		
			
				
	
				Docs CI / lint-and-preview (push) Has been cancelled
				
			
		
		
	
	
				
					
				
			
		
			Some checks failed
		
		
	
	Build Test Deploy / authority-container (push) Has been cancelled
				
			Build Test Deploy / docs (push) Has been cancelled
				
			Build Test Deploy / deploy (push) Has been cancelled
				
			Build Test Deploy / build-test (push) Has been cancelled
				
			Docs CI / lint-and-preview (push) Has been cancelled
				
			This commit is contained in:
		
							
								
								
									
										533
									
								
								src/StellaOps.Cli.Tests/Commands/CommandHandlersTests.cs
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										533
									
								
								src/StellaOps.Cli.Tests/Commands/CommandHandlersTests.cs
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,533 @@ | ||||
| using System; | ||||
| using System.Collections.Generic; | ||||
| using System.IO; | ||||
| using System.Text; | ||||
| using System.Text.Json; | ||||
| using System.Threading; | ||||
| using System.Threading.Tasks; | ||||
| using Microsoft.Extensions.DependencyInjection; | ||||
| using Microsoft.Extensions.Logging; | ||||
| using Microsoft.IdentityModel.Tokens; | ||||
| using StellaOps.Auth.Abstractions; | ||||
| using StellaOps.Auth.Client; | ||||
| using StellaOps.Cli.Commands; | ||||
| using StellaOps.Cli.Configuration; | ||||
| using StellaOps.Cli.Services; | ||||
| using StellaOps.Cli.Services.Models; | ||||
| using StellaOps.Cli.Telemetry; | ||||
| using StellaOps.Cli.Tests.Testing; | ||||
|  | ||||
| namespace StellaOps.Cli.Tests.Commands; | ||||
|  | ||||
| public sealed class CommandHandlersTests | ||||
| { | ||||
|     [Fact] | ||||
|     public async Task HandleExportJobAsync_SetsExitCodeZeroOnSuccess() | ||||
|     { | ||||
|         var original = Environment.ExitCode; | ||||
|         try | ||||
|         { | ||||
|             var backend = new StubBackendClient(new JobTriggerResult(true, "Accepted", "/jobs/export:json/1", null)); | ||||
|             var provider = BuildServiceProvider(backend); | ||||
|  | ||||
|             await CommandHandlers.HandleExportJobAsync( | ||||
|                 provider, | ||||
|                 format: "json", | ||||
|                 delta: false, | ||||
|                 publishFull: null, | ||||
|                 publishDelta: null, | ||||
|                 includeFull: null, | ||||
|                 includeDelta: null, | ||||
|                 verbose: false, | ||||
|                 cancellationToken: CancellationToken.None); | ||||
|  | ||||
|             Assert.Equal(0, Environment.ExitCode); | ||||
|             Assert.Equal("export:json", backend.LastJobKind); | ||||
|         } | ||||
|         finally | ||||
|         { | ||||
|             Environment.ExitCode = original; | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     [Fact] | ||||
|     public async Task HandleMergeJobAsync_SetsExitCodeOnFailure() | ||||
|     { | ||||
|         var original = Environment.ExitCode; | ||||
|         try | ||||
|         { | ||||
|             var backend = new StubBackendClient(new JobTriggerResult(false, "Job already running", null, null)); | ||||
|             var provider = BuildServiceProvider(backend); | ||||
|  | ||||
|             await CommandHandlers.HandleMergeJobAsync(provider, verbose: false, CancellationToken.None); | ||||
|  | ||||
|             Assert.Equal(1, Environment.ExitCode); | ||||
|             Assert.Equal("merge:reconcile", backend.LastJobKind); | ||||
|         } | ||||
|         finally | ||||
|         { | ||||
|             Environment.ExitCode = original; | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     [Fact] | ||||
|     public async Task HandleScannerRunAsync_AutomaticallyUploadsResults() | ||||
|     { | ||||
|         using var tempDir = new TempDirectory(); | ||||
|         var resultsFile = Path.Combine(tempDir.Path, "results", "scan.json"); | ||||
|         var backend = new StubBackendClient(new JobTriggerResult(true, "Accepted", null, null)); | ||||
|         var metadataFile = Path.Combine(tempDir.Path, "results", "scan-run.json"); | ||||
|         var executor = new StubExecutor(new ScannerExecutionResult(0, resultsFile, metadataFile)); | ||||
|         var options = new StellaOpsCliOptions | ||||
|         { | ||||
|             ResultsDirectory = Path.Combine(tempDir.Path, "results") | ||||
|         }; | ||||
|  | ||||
|         var provider = BuildServiceProvider(backend, executor, new StubInstaller(), options); | ||||
|  | ||||
|         Directory.CreateDirectory(Path.Combine(tempDir.Path, "target")); | ||||
|  | ||||
|         var original = Environment.ExitCode; | ||||
|         try | ||||
|         { | ||||
|             await CommandHandlers.HandleScannerRunAsync( | ||||
|                 provider, | ||||
|                 runner: "docker", | ||||
|                 entry: "scanner-image", | ||||
|                 targetDirectory: Path.Combine(tempDir.Path, "target"), | ||||
|                 arguments: Array.Empty<string>(), | ||||
|                 verbose: false, | ||||
|                 cancellationToken: CancellationToken.None); | ||||
|  | ||||
|             Assert.Equal(0, Environment.ExitCode); | ||||
|             Assert.Equal(resultsFile, backend.LastUploadPath); | ||||
|             Assert.True(File.Exists(metadataFile)); | ||||
|         } | ||||
|         finally | ||||
|         { | ||||
|             Environment.ExitCode = original; | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     [Fact] | ||||
|     public async Task HandleAuthLoginAsync_UsesClientCredentialsFlow() | ||||
|     { | ||||
|         var original = Environment.ExitCode; | ||||
|         using var tempDir = new TempDirectory(); | ||||
|  | ||||
|         try | ||||
|         { | ||||
|             var options = new StellaOpsCliOptions | ||||
|             { | ||||
|                 ResultsDirectory = Path.Combine(tempDir.Path, "results"), | ||||
|                 Authority = new StellaOpsCliAuthorityOptions | ||||
|                 { | ||||
|                     Url = "https://authority.example", | ||||
|                     ClientId = "cli", | ||||
|                     ClientSecret = "secret", | ||||
|                     Scope = "feedser.jobs.trigger", | ||||
|                     TokenCacheDirectory = tempDir.Path | ||||
|                 } | ||||
|             }; | ||||
|  | ||||
|             var tokenClient = new StubTokenClient(); | ||||
|             var provider = BuildServiceProvider(new StubBackendClient(new JobTriggerResult(true, "ok", null, null)), options: options, tokenClient: tokenClient); | ||||
|  | ||||
|             await CommandHandlers.HandleAuthLoginAsync(provider, options, verbose: false, force: false, cancellationToken: CancellationToken.None); | ||||
|  | ||||
|             Assert.Equal(0, Environment.ExitCode); | ||||
|             Assert.Equal(1, tokenClient.ClientCredentialRequests); | ||||
|             Assert.NotNull(tokenClient.CachedEntry); | ||||
|         } | ||||
|         finally | ||||
|         { | ||||
|             Environment.ExitCode = original; | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     [Fact] | ||||
|     public async Task HandleAuthLoginAsync_FailsWhenPasswordMissing() | ||||
|     { | ||||
|         var original = Environment.ExitCode; | ||||
|         using var tempDir = new TempDirectory(); | ||||
|  | ||||
|         try | ||||
|         { | ||||
|             var options = new StellaOpsCliOptions | ||||
|             { | ||||
|                 ResultsDirectory = Path.Combine(tempDir.Path, "results"), | ||||
|                 Authority = new StellaOpsCliAuthorityOptions | ||||
|                 { | ||||
|                     Url = "https://authority.example", | ||||
|                     ClientId = "cli", | ||||
|                     Username = "user", | ||||
|                     TokenCacheDirectory = tempDir.Path | ||||
|                 } | ||||
|             }; | ||||
|  | ||||
|             var provider = BuildServiceProvider(new StubBackendClient(new JobTriggerResult(true, "ok", null, null)), options: options, tokenClient: new StubTokenClient()); | ||||
|  | ||||
|             await CommandHandlers.HandleAuthLoginAsync(provider, options, verbose: false, force: false, cancellationToken: CancellationToken.None); | ||||
|  | ||||
|             Assert.Equal(1, Environment.ExitCode); | ||||
|         } | ||||
|         finally | ||||
|         { | ||||
|             Environment.ExitCode = original; | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     [Fact] | ||||
|     public async Task HandleAuthStatusAsync_ReportsMissingToken() | ||||
|     { | ||||
|         var original = Environment.ExitCode; | ||||
|         using var tempDir = new TempDirectory(); | ||||
|  | ||||
|         try | ||||
|         { | ||||
|             var options = new StellaOpsCliOptions | ||||
|             { | ||||
|                 ResultsDirectory = Path.Combine(tempDir.Path, "results"), | ||||
|                 Authority = new StellaOpsCliAuthorityOptions | ||||
|                 { | ||||
|                     Url = "https://authority.example", | ||||
|                     ClientId = "cli", | ||||
|                     TokenCacheDirectory = tempDir.Path | ||||
|                 } | ||||
|             }; | ||||
|  | ||||
|             var provider = BuildServiceProvider(new StubBackendClient(new JobTriggerResult(true, "ok", null, null)), options: options, tokenClient: new StubTokenClient()); | ||||
|  | ||||
|             await CommandHandlers.HandleAuthStatusAsync(provider, options, verbose: false, cancellationToken: CancellationToken.None); | ||||
|  | ||||
|             Assert.Equal(1, Environment.ExitCode); | ||||
|         } | ||||
|         finally | ||||
|         { | ||||
|             Environment.ExitCode = original; | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     [Fact] | ||||
|     public async Task HandleAuthStatusAsync_ReportsCachedToken() | ||||
|     { | ||||
|         var original = Environment.ExitCode; | ||||
|         using var tempDir = new TempDirectory(); | ||||
|  | ||||
|         try | ||||
|         { | ||||
|             var options = new StellaOpsCliOptions | ||||
|             { | ||||
|                 ResultsDirectory = Path.Combine(tempDir.Path, "results"), | ||||
|                 Authority = new StellaOpsCliAuthorityOptions | ||||
|                 { | ||||
|                     Url = "https://authority.example", | ||||
|                     ClientId = "cli", | ||||
|                     TokenCacheDirectory = tempDir.Path | ||||
|                 } | ||||
|             }; | ||||
|  | ||||
|             var tokenClient = new StubTokenClient(); | ||||
|             tokenClient.CachedEntry = new StellaOpsTokenCacheEntry( | ||||
|                 "token", | ||||
|                 "Bearer", | ||||
|                 DateTimeOffset.UtcNow.AddMinutes(30), | ||||
|                 new[] { StellaOpsScopes.FeedserJobsTrigger }); | ||||
|  | ||||
|             var provider = BuildServiceProvider(new StubBackendClient(new JobTriggerResult(true, "ok", null, null)), options: options, tokenClient: tokenClient); | ||||
|  | ||||
|             await CommandHandlers.HandleAuthStatusAsync(provider, options, verbose: true, cancellationToken: CancellationToken.None); | ||||
|  | ||||
|             Assert.Equal(0, Environment.ExitCode); | ||||
|         } | ||||
|         finally | ||||
|         { | ||||
|             Environment.ExitCode = original; | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     [Fact] | ||||
|     public async Task HandleAuthWhoAmIAsync_ReturnsErrorWhenTokenMissing() | ||||
|     { | ||||
|         var original = Environment.ExitCode; | ||||
|         using var tempDir = new TempDirectory(); | ||||
|  | ||||
|         try | ||||
|         { | ||||
|             var options = new StellaOpsCliOptions | ||||
|             { | ||||
|                 ResultsDirectory = Path.Combine(tempDir.Path, "results"), | ||||
|                 Authority = new StellaOpsCliAuthorityOptions | ||||
|                 { | ||||
|                     Url = "https://authority.example", | ||||
|                     ClientId = "cli", | ||||
|                     TokenCacheDirectory = tempDir.Path | ||||
|                 } | ||||
|             }; | ||||
|  | ||||
|             var provider = BuildServiceProvider(new StubBackendClient(new JobTriggerResult(true, "ok", null, null)), options: options, tokenClient: new StubTokenClient()); | ||||
|  | ||||
|             await CommandHandlers.HandleAuthWhoAmIAsync(provider, options, verbose: false, cancellationToken: CancellationToken.None); | ||||
|  | ||||
|             Assert.Equal(1, Environment.ExitCode); | ||||
|         } | ||||
|         finally | ||||
|         { | ||||
|             Environment.ExitCode = original; | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     [Fact] | ||||
|     public async Task HandleAuthWhoAmIAsync_ReportsClaimsForJwtToken() | ||||
|     { | ||||
|         var original = Environment.ExitCode; | ||||
|         using var tempDir = new TempDirectory(); | ||||
|  | ||||
|         try | ||||
|         { | ||||
|             var options = new StellaOpsCliOptions | ||||
|             { | ||||
|                 ResultsDirectory = Path.Combine(tempDir.Path, "results"), | ||||
|                 Authority = new StellaOpsCliAuthorityOptions | ||||
|                 { | ||||
|                     Url = "https://authority.example", | ||||
|                     ClientId = "cli", | ||||
|                     TokenCacheDirectory = tempDir.Path | ||||
|                 } | ||||
|             }; | ||||
|  | ||||
|             var tokenClient = new StubTokenClient(); | ||||
|             tokenClient.CachedEntry = new StellaOpsTokenCacheEntry( | ||||
|                 CreateUnsignedJwt( | ||||
|                     ("sub", "cli-user"), | ||||
|                     ("aud", "feedser"), | ||||
|                     ("iss", "https://authority.example"), | ||||
|                     ("iat", 1_700_000_000), | ||||
|                     ("nbf", 1_700_000_000)), | ||||
|                 "Bearer", | ||||
|                 DateTimeOffset.UtcNow.AddMinutes(30), | ||||
|                 new[] { StellaOpsScopes.FeedserJobsTrigger }); | ||||
|  | ||||
|             var provider = BuildServiceProvider(new StubBackendClient(new JobTriggerResult(true, "ok", null, null)), options: options, tokenClient: tokenClient); | ||||
|  | ||||
|             await CommandHandlers.HandleAuthWhoAmIAsync(provider, options, verbose: true, cancellationToken: CancellationToken.None); | ||||
|  | ||||
|             Assert.Equal(0, Environment.ExitCode); | ||||
|         } | ||||
|         finally | ||||
|         { | ||||
|             Environment.ExitCode = original; | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     [Fact] | ||||
|     public async Task HandleAuthLogoutAsync_ClearsToken() | ||||
|     { | ||||
|         var original = Environment.ExitCode; | ||||
|         using var tempDir = new TempDirectory(); | ||||
|  | ||||
|         try | ||||
|         { | ||||
|             var options = new StellaOpsCliOptions | ||||
|             { | ||||
|                 ResultsDirectory = Path.Combine(tempDir.Path, "results"), | ||||
|                 Authority = new StellaOpsCliAuthorityOptions | ||||
|                 { | ||||
|                     Url = "https://authority.example", | ||||
|                     ClientId = "cli", | ||||
|                     TokenCacheDirectory = tempDir.Path | ||||
|                 } | ||||
|             }; | ||||
|  | ||||
|             var tokenClient = new StubTokenClient(); | ||||
|             tokenClient.CachedEntry = new StellaOpsTokenCacheEntry( | ||||
|                 "token", | ||||
|                 "Bearer", | ||||
|                 DateTimeOffset.UtcNow.AddMinutes(5), | ||||
|                 new[] { StellaOpsScopes.FeedserJobsTrigger }); | ||||
|  | ||||
|             var provider = BuildServiceProvider(new StubBackendClient(new JobTriggerResult(true, "ok", null, null)), options: options, tokenClient: tokenClient); | ||||
|  | ||||
|             await CommandHandlers.HandleAuthLogoutAsync(provider, options, verbose: true, cancellationToken: CancellationToken.None); | ||||
|  | ||||
|             Assert.Null(tokenClient.CachedEntry); | ||||
|             Assert.Equal(1, tokenClient.ClearRequests); | ||||
|             Assert.Equal(0, Environment.ExitCode); | ||||
|         } | ||||
|         finally | ||||
|         { | ||||
|             Environment.ExitCode = original; | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     private static IServiceProvider BuildServiceProvider( | ||||
|         IBackendOperationsClient backend, | ||||
|         IScannerExecutor? executor = null, | ||||
|         IScannerInstaller? installer = null, | ||||
|         StellaOpsCliOptions? options = null, | ||||
|         IStellaOpsTokenClient? tokenClient = null) | ||||
|     { | ||||
|         var services = new ServiceCollection(); | ||||
|         services.AddSingleton(backend); | ||||
|         services.AddSingleton<ILoggerFactory>(_ => LoggerFactory.Create(builder => builder.SetMinimumLevel(LogLevel.Debug))); | ||||
|         services.AddSingleton(new VerbosityState()); | ||||
|         var resolvedOptions = options ?? new StellaOpsCliOptions | ||||
|         { | ||||
|             ResultsDirectory = Path.Combine(Path.GetTempPath(), $"stellaops-cli-results-{Guid.NewGuid():N}") | ||||
|         }; | ||||
|         services.AddSingleton(resolvedOptions); | ||||
|  | ||||
|         var resolvedExecutor = executor ?? CreateDefaultExecutor(); | ||||
|         services.AddSingleton<IScannerExecutor>(resolvedExecutor); | ||||
|         services.AddSingleton<IScannerInstaller>(installer ?? new StubInstaller()); | ||||
|  | ||||
|         if (tokenClient is not null) | ||||
|         { | ||||
|             services.AddSingleton(tokenClient); | ||||
|         } | ||||
|  | ||||
|         return services.BuildServiceProvider(); | ||||
|     } | ||||
|  | ||||
|     private static IScannerExecutor CreateDefaultExecutor() | ||||
|     { | ||||
|         var tempResultsFile = Path.GetTempFileName(); | ||||
|         var tempMetadataFile = Path.Combine( | ||||
|             Path.GetDirectoryName(tempResultsFile)!, | ||||
|             $"{Path.GetFileNameWithoutExtension(tempResultsFile)}-run.json"); | ||||
|         return new StubExecutor(new ScannerExecutionResult(0, tempResultsFile, tempMetadataFile)); | ||||
|     } | ||||
|  | ||||
|     private sealed class StubBackendClient : IBackendOperationsClient | ||||
|     { | ||||
|         private readonly JobTriggerResult _result; | ||||
|  | ||||
|         public StubBackendClient(JobTriggerResult result) | ||||
|         { | ||||
|             _result = result; | ||||
|         } | ||||
|  | ||||
|         public string? LastJobKind { get; private set; } | ||||
|         public string? LastUploadPath { get; private set; } | ||||
|  | ||||
|         public Task<ScannerArtifactResult> DownloadScannerAsync(string channel, string outputPath, bool overwrite, bool verbose, CancellationToken cancellationToken) | ||||
|             => throw new NotImplementedException(); | ||||
|  | ||||
|         public Task UploadScanResultsAsync(string filePath, CancellationToken cancellationToken) | ||||
|         { | ||||
|             LastUploadPath = filePath; | ||||
|             return Task.CompletedTask; | ||||
|         } | ||||
|  | ||||
|         public Task<JobTriggerResult> TriggerJobAsync(string jobKind, IDictionary<string, object?> parameters, CancellationToken cancellationToken) | ||||
|         { | ||||
|             LastJobKind = jobKind; | ||||
|             return Task.FromResult(_result); | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     private sealed class StubExecutor : IScannerExecutor | ||||
|     { | ||||
|         private readonly ScannerExecutionResult _result; | ||||
|  | ||||
|         public StubExecutor(ScannerExecutionResult result) | ||||
|         { | ||||
|             _result = result; | ||||
|         } | ||||
|  | ||||
|         public Task<ScannerExecutionResult> RunAsync(string runner, string entry, string targetDirectory, string resultsDirectory, IReadOnlyList<string> arguments, bool verbose, CancellationToken cancellationToken) | ||||
|         { | ||||
|             Directory.CreateDirectory(Path.GetDirectoryName(_result.ResultsPath)!); | ||||
|             if (!File.Exists(_result.ResultsPath)) | ||||
|             { | ||||
|                 File.WriteAllText(_result.ResultsPath, "{}"); | ||||
|             } | ||||
|  | ||||
|             Directory.CreateDirectory(Path.GetDirectoryName(_result.RunMetadataPath)!); | ||||
|             if (!File.Exists(_result.RunMetadataPath)) | ||||
|             { | ||||
|                 File.WriteAllText(_result.RunMetadataPath, "{}"); | ||||
|             } | ||||
|  | ||||
|             return Task.FromResult(_result); | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     private sealed class StubInstaller : IScannerInstaller | ||||
|     { | ||||
|         public Task InstallAsync(string artifactPath, bool verbose, CancellationToken cancellationToken) | ||||
|             => Task.CompletedTask; | ||||
|     } | ||||
|  | ||||
|     private sealed class StubTokenClient : IStellaOpsTokenClient | ||||
|     { | ||||
|         private readonly StellaOpsTokenResult _token; | ||||
|  | ||||
|         public StubTokenClient() | ||||
|         { | ||||
|             _token = new StellaOpsTokenResult( | ||||
|                 "token-123", | ||||
|                 "Bearer", | ||||
|                 DateTimeOffset.UtcNow.AddMinutes(30), | ||||
|                 new[] { StellaOpsScopes.FeedserJobsTrigger }); | ||||
|         } | ||||
|  | ||||
|         public int ClientCredentialRequests { get; private set; } | ||||
|         public int PasswordRequests { get; private set; } | ||||
|         public int ClearRequests { get; private set; } | ||||
|         public StellaOpsTokenCacheEntry? CachedEntry { get; set; } | ||||
|  | ||||
|         public ValueTask CacheTokenAsync(string key, StellaOpsTokenCacheEntry entry, CancellationToken cancellationToken = default) | ||||
|         { | ||||
|             CachedEntry = entry; | ||||
|             return ValueTask.CompletedTask; | ||||
|         } | ||||
|  | ||||
|         public ValueTask ClearCachedTokenAsync(string key, CancellationToken cancellationToken = default) | ||||
|         { | ||||
|             ClearRequests++; | ||||
|             CachedEntry = null; | ||||
|             return ValueTask.CompletedTask; | ||||
|         } | ||||
|  | ||||
|         public Task<JsonWebKeySet> GetJsonWebKeySetAsync(CancellationToken cancellationToken = default) | ||||
|             => Task.FromResult(new JsonWebKeySet("{\"keys\":[]}")); | ||||
|  | ||||
|         public ValueTask<StellaOpsTokenCacheEntry?> GetCachedTokenAsync(string key, CancellationToken cancellationToken = default) | ||||
|             => ValueTask.FromResult(CachedEntry); | ||||
|  | ||||
|         public Task<StellaOpsTokenResult> RequestClientCredentialsTokenAsync(string? scope = null, CancellationToken cancellationToken = default) | ||||
|         { | ||||
|             ClientCredentialRequests++; | ||||
|             return Task.FromResult(_token); | ||||
|         } | ||||
|  | ||||
|         public Task<StellaOpsTokenResult> RequestPasswordTokenAsync(string username, string password, string? scope = null, CancellationToken cancellationToken = default) | ||||
|         { | ||||
|             PasswordRequests++; | ||||
|             return Task.FromResult(_token); | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     private static string CreateUnsignedJwt(params (string Key, object Value)[] claims) | ||||
|     { | ||||
|         var headerJson = "{\"alg\":\"none\",\"typ\":\"JWT\"}"; | ||||
|         var payload = new Dictionary<string, object?>(StringComparer.Ordinal); | ||||
|         foreach (var claim in claims) | ||||
|         { | ||||
|             payload[claim.Key] = claim.Value; | ||||
|         } | ||||
|  | ||||
|         var payloadJson = JsonSerializer.Serialize(payload); | ||||
|         return $"{Base64UrlEncode(headerJson)}.{Base64UrlEncode(payloadJson)}."; | ||||
|     } | ||||
|  | ||||
|     private static string Base64UrlEncode(string value) | ||||
|     { | ||||
|         var bytes = Encoding.UTF8.GetBytes(value); | ||||
|         return Convert.ToBase64String(bytes) | ||||
|             .TrimEnd('=') | ||||
|             .Replace('+', '-') | ||||
|             .Replace('/', '_'); | ||||
|     } | ||||
| } | ||||
							
								
								
									
										110
									
								
								src/StellaOps.Cli.Tests/Configuration/CliBootstrapperTests.cs
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										110
									
								
								src/StellaOps.Cli.Tests/Configuration/CliBootstrapperTests.cs
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,110 @@ | ||||
| using System; | ||||
| using System.IO; | ||||
| using System.Text.Json; | ||||
| using StellaOps.Cli.Configuration; | ||||
| using Xunit; | ||||
|  | ||||
| namespace StellaOps.Cli.Tests.Configuration; | ||||
|  | ||||
| public sealed class CliBootstrapperTests : IDisposable | ||||
| { | ||||
|     private readonly string _originalDirectory = Directory.GetCurrentDirectory(); | ||||
|     private readonly string _tempDirectory = Path.Combine(Path.GetTempPath(), $"stellaops-cli-tests-{Guid.NewGuid():N}"); | ||||
|  | ||||
|     public CliBootstrapperTests() | ||||
|     { | ||||
|         Directory.CreateDirectory(_tempDirectory); | ||||
|         Directory.SetCurrentDirectory(_tempDirectory); | ||||
|     } | ||||
|  | ||||
|     [Fact] | ||||
|     public void Build_UsesEnvironmentVariablesWhenPresent() | ||||
|     { | ||||
|         Environment.SetEnvironmentVariable("API_KEY", "env-key"); | ||||
|         Environment.SetEnvironmentVariable("STELLAOPS_BACKEND_URL", "https://env-backend.example"); | ||||
|         Environment.SetEnvironmentVariable("STELLAOPS_AUTHORITY_URL", "https://authority.env"); | ||||
|         Environment.SetEnvironmentVariable("STELLAOPS_AUTHORITY_CLIENT_ID", "cli-env"); | ||||
|         Environment.SetEnvironmentVariable("STELLAOPS_AUTHORITY_SCOPE", "feedser.jobs.trigger"); | ||||
|         Environment.SetEnvironmentVariable("STELLAOPS_AUTHORITY_ENABLE_RETRIES", "false"); | ||||
|         Environment.SetEnvironmentVariable("STELLAOPS_AUTHORITY_RETRY_DELAYS", "00:00:02,00:00:05"); | ||||
|         Environment.SetEnvironmentVariable("STELLAOPS_AUTHORITY_ALLOW_OFFLINE_CACHE_FALLBACK", "false"); | ||||
|         Environment.SetEnvironmentVariable("STELLAOPS_AUTHORITY_OFFLINE_CACHE_TOLERANCE", "00:20:00"); | ||||
|  | ||||
|         try | ||||
|         { | ||||
|             var (options, _) = CliBootstrapper.Build(Array.Empty<string>()); | ||||
|  | ||||
|             Assert.Equal("env-key", options.ApiKey); | ||||
|             Assert.Equal("https://env-backend.example", options.BackendUrl); | ||||
|             Assert.Equal("https://authority.env", options.Authority.Url); | ||||
|             Assert.Equal("cli-env", options.Authority.ClientId); | ||||
|             Assert.Equal("feedser.jobs.trigger", options.Authority.Scope); | ||||
|  | ||||
|             Assert.NotNull(options.Authority.Resilience); | ||||
|             Assert.False(options.Authority.Resilience.EnableRetries); | ||||
|             Assert.Equal(new[] { TimeSpan.FromSeconds(2), TimeSpan.FromSeconds(5) }, options.Authority.Resilience.RetryDelays); | ||||
|             Assert.False(options.Authority.Resilience.AllowOfflineCacheFallback); | ||||
|             Assert.Equal(TimeSpan.FromMinutes(20), options.Authority.Resilience.OfflineCacheTolerance); | ||||
|         } | ||||
|         finally | ||||
|         { | ||||
|             Environment.SetEnvironmentVariable("API_KEY", null); | ||||
|             Environment.SetEnvironmentVariable("STELLAOPS_BACKEND_URL", null); | ||||
|             Environment.SetEnvironmentVariable("STELLAOPS_AUTHORITY_URL", null); | ||||
|             Environment.SetEnvironmentVariable("STELLAOPS_AUTHORITY_CLIENT_ID", null); | ||||
|             Environment.SetEnvironmentVariable("STELLAOPS_AUTHORITY_SCOPE", null); | ||||
|             Environment.SetEnvironmentVariable("STELLAOPS_AUTHORITY_ENABLE_RETRIES", null); | ||||
|             Environment.SetEnvironmentVariable("STELLAOPS_AUTHORITY_RETRY_DELAYS", null); | ||||
|             Environment.SetEnvironmentVariable("STELLAOPS_AUTHORITY_ALLOW_OFFLINE_CACHE_FALLBACK", null); | ||||
|             Environment.SetEnvironmentVariable("STELLAOPS_AUTHORITY_OFFLINE_CACHE_TOLERANCE", null); | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     [Fact] | ||||
|     public void Build_FallsBackToAppSettings() | ||||
|     { | ||||
|         WriteAppSettings(new | ||||
|         { | ||||
|             StellaOps = new | ||||
|             { | ||||
|                 ApiKey = "file-key", | ||||
|                 BackendUrl = "https://file-backend.example", | ||||
|                 Authority = new | ||||
|                 { | ||||
|                     Url = "https://authority.file", | ||||
|                     ClientId = "cli-file", | ||||
|                     Scope = "feedser.jobs.trigger" | ||||
|                 } | ||||
|             } | ||||
|         }); | ||||
|  | ||||
|         var (options, _) = CliBootstrapper.Build(Array.Empty<string>()); | ||||
|  | ||||
|         Assert.Equal("file-key", options.ApiKey); | ||||
|         Assert.Equal("https://file-backend.example", options.BackendUrl); | ||||
|         Assert.Equal("https://authority.file", options.Authority.Url); | ||||
|         Assert.Equal("cli-file", options.Authority.ClientId); | ||||
|     } | ||||
|  | ||||
|     public void Dispose() | ||||
|     { | ||||
|         Directory.SetCurrentDirectory(_originalDirectory); | ||||
|         if (Directory.Exists(_tempDirectory)) | ||||
|         { | ||||
|             try | ||||
|             { | ||||
|                 Directory.Delete(_tempDirectory, recursive: true); | ||||
|             } | ||||
|             catch | ||||
|             { | ||||
|                 // Ignored. | ||||
|             } | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     private static void WriteAppSettings<T>(T payload) | ||||
|     { | ||||
|         var json = JsonSerializer.Serialize(payload, new JsonSerializerOptions { WriteIndented = true }); | ||||
|         File.WriteAllText("appsettings.json", json); | ||||
|     } | ||||
| } | ||||
							
								
								
									
										417
									
								
								src/StellaOps.Cli.Tests/Services/BackendOperationsClientTests.cs
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										417
									
								
								src/StellaOps.Cli.Tests/Services/BackendOperationsClientTests.cs
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,417 @@ | ||||
| using System; | ||||
| using System.IO; | ||||
| using System.Net; | ||||
| using System.Net.Http; | ||||
| using System.Net.Http.Json; | ||||
| using System.Security.Cryptography; | ||||
| using System.Text; | ||||
| using System.Text.Json; | ||||
| using System.Threading; | ||||
| using System.Threading.Tasks; | ||||
| using Microsoft.Extensions.Logging; | ||||
| using Microsoft.IdentityModel.Tokens; | ||||
| using StellaOps.Auth.Abstractions; | ||||
| using StellaOps.Auth.Client; | ||||
| using StellaOps.Cli.Configuration; | ||||
| using StellaOps.Cli.Services; | ||||
| using StellaOps.Cli.Services.Models; | ||||
| using StellaOps.Cli.Services.Models.Transport; | ||||
| using StellaOps.Cli.Tests.Testing; | ||||
|  | ||||
| namespace StellaOps.Cli.Tests.Services; | ||||
|  | ||||
| public sealed class BackendOperationsClientTests | ||||
| { | ||||
|     [Fact] | ||||
|     public async Task DownloadScannerAsync_VerifiesDigestAndWritesMetadata() | ||||
|     { | ||||
|         using var temp = new TempDirectory(); | ||||
|  | ||||
|         var contentBytes = Encoding.UTF8.GetBytes("scanner-blob"); | ||||
|         var digestHex = Convert.ToHexString(SHA256.HashData(contentBytes)).ToLowerInvariant(); | ||||
|  | ||||
|         var handler = new StubHttpMessageHandler((request, _) => | ||||
|         { | ||||
|             var response = new HttpResponseMessage(HttpStatusCode.OK) | ||||
|             { | ||||
|                 Content = new ByteArrayContent(contentBytes), | ||||
|                 RequestMessage = request | ||||
|             }; | ||||
|  | ||||
|             response.Headers.Add("X-StellaOps-Digest", $"sha256:{digestHex}"); | ||||
|             response.Content.Headers.LastModified = DateTimeOffset.UtcNow; | ||||
|             response.Content.Headers.ContentType = new System.Net.Http.Headers.MediaTypeHeaderValue("application/octet-stream"); | ||||
|             return response; | ||||
|         }); | ||||
|  | ||||
|         var httpClient = new HttpClient(handler) | ||||
|         { | ||||
|             BaseAddress = new Uri("https://feedser.example") | ||||
|         }; | ||||
|  | ||||
|         var options = new StellaOpsCliOptions | ||||
|         { | ||||
|             BackendUrl = "https://feedser.example", | ||||
|             ScannerCacheDirectory = temp.Path, | ||||
|             ScannerDownloadAttempts = 1 | ||||
|         }; | ||||
|  | ||||
|         var loggerFactory = LoggerFactory.Create(builder => builder.SetMinimumLevel(LogLevel.Debug)); | ||||
|         var client = new BackendOperationsClient(httpClient, options, loggerFactory.CreateLogger<BackendOperationsClient>()); | ||||
|  | ||||
|         var targetPath = Path.Combine(temp.Path, "scanner.tar.gz"); | ||||
|         var result = await client.DownloadScannerAsync("stable", targetPath, overwrite: false, verbose: true, CancellationToken.None); | ||||
|  | ||||
|         Assert.False(result.FromCache); | ||||
|         Assert.True(File.Exists(targetPath)); | ||||
|  | ||||
|         var metadataPath = targetPath + ".metadata.json"; | ||||
|         Assert.True(File.Exists(metadataPath)); | ||||
|  | ||||
|         using var document = JsonDocument.Parse(File.ReadAllText(metadataPath)); | ||||
|         Assert.Equal($"sha256:{digestHex}", document.RootElement.GetProperty("digest").GetString()); | ||||
|         Assert.Equal("stable", document.RootElement.GetProperty("channel").GetString()); | ||||
|     } | ||||
|  | ||||
|     [Fact] | ||||
|     public async Task DownloadScannerAsync_ThrowsOnDigestMismatch() | ||||
|     { | ||||
|         using var temp = new TempDirectory(); | ||||
|  | ||||
|         var contentBytes = Encoding.UTF8.GetBytes("scanner-data"); | ||||
|         var handler = new StubHttpMessageHandler((request, _) => | ||||
|         { | ||||
|             var response = new HttpResponseMessage(HttpStatusCode.OK) | ||||
|             { | ||||
|                 Content = new ByteArrayContent(contentBytes), | ||||
|                 RequestMessage = request | ||||
|             }; | ||||
|             response.Headers.Add("X-StellaOps-Digest", "sha256:deadbeef"); | ||||
|             return response; | ||||
|         }); | ||||
|  | ||||
|         var httpClient = new HttpClient(handler) | ||||
|         { | ||||
|             BaseAddress = new Uri("https://feedser.example") | ||||
|         }; | ||||
|  | ||||
|         var options = new StellaOpsCliOptions | ||||
|         { | ||||
|             BackendUrl = "https://feedser.example", | ||||
|             ScannerCacheDirectory = temp.Path, | ||||
|             ScannerDownloadAttempts = 1 | ||||
|         }; | ||||
|  | ||||
|         var loggerFactory = LoggerFactory.Create(builder => builder.SetMinimumLevel(LogLevel.Debug)); | ||||
|         var client = new BackendOperationsClient(httpClient, options, loggerFactory.CreateLogger<BackendOperationsClient>()); | ||||
|  | ||||
|         var targetPath = Path.Combine(temp.Path, "scanner.tar.gz"); | ||||
|  | ||||
|         await Assert.ThrowsAsync<InvalidOperationException>(() => client.DownloadScannerAsync("stable", targetPath, overwrite: true, verbose: false, CancellationToken.None)); | ||||
|         Assert.False(File.Exists(targetPath)); | ||||
|     } | ||||
|  | ||||
|     [Fact] | ||||
|     public async Task DownloadScannerAsync_RetriesOnFailure() | ||||
|     { | ||||
|         using var temp = new TempDirectory(); | ||||
|  | ||||
|         var successBytes = Encoding.UTF8.GetBytes("success"); | ||||
|         var digestHex = Convert.ToHexString(SHA256.HashData(successBytes)).ToLowerInvariant(); | ||||
|         var attempts = 0; | ||||
|  | ||||
|         var handler = new StubHttpMessageHandler( | ||||
|             (request, _) => | ||||
|             { | ||||
|                 attempts++; | ||||
|                 return new HttpResponseMessage(HttpStatusCode.InternalServerError) | ||||
|                 { | ||||
|                     RequestMessage = request, | ||||
|                     Content = new StringContent("error") | ||||
|                 }; | ||||
|             }, | ||||
|             (request, _) => | ||||
|             { | ||||
|                 attempts++; | ||||
|                 var response = new HttpResponseMessage(HttpStatusCode.OK) | ||||
|                 { | ||||
|                     RequestMessage = request, | ||||
|                     Content = new ByteArrayContent(successBytes) | ||||
|                 }; | ||||
|                 response.Headers.Add("X-StellaOps-Digest", $"sha256:{digestHex}"); | ||||
|                 return response; | ||||
|             }); | ||||
|  | ||||
|         var httpClient = new HttpClient(handler) | ||||
|         { | ||||
|             BaseAddress = new Uri("https://feedser.example") | ||||
|         }; | ||||
|  | ||||
|         var options = new StellaOpsCliOptions | ||||
|         { | ||||
|             BackendUrl = "https://feedser.example", | ||||
|             ScannerCacheDirectory = temp.Path, | ||||
|             ScannerDownloadAttempts = 3 | ||||
|         }; | ||||
|  | ||||
|         var loggerFactory = LoggerFactory.Create(builder => builder.SetMinimumLevel(LogLevel.Debug)); | ||||
|         var client = new BackendOperationsClient(httpClient, options, loggerFactory.CreateLogger<BackendOperationsClient>()); | ||||
|  | ||||
|         var targetPath = Path.Combine(temp.Path, "scanner.tar.gz"); | ||||
|         var result = await client.DownloadScannerAsync("stable", targetPath, overwrite: false, verbose: false, CancellationToken.None); | ||||
|  | ||||
|         Assert.Equal(2, attempts); | ||||
|         Assert.False(result.FromCache); | ||||
|         Assert.True(File.Exists(targetPath)); | ||||
|     } | ||||
|  | ||||
|     [Fact] | ||||
|     public async Task UploadScanResultsAsync_RetriesOnRetryAfter() | ||||
|     { | ||||
|         using var temp = new TempDirectory(); | ||||
|         var filePath = Path.Combine(temp.Path, "scan.json"); | ||||
|         await File.WriteAllTextAsync(filePath, "{}"); | ||||
|  | ||||
|         var attempts = 0; | ||||
|         var handler = new StubHttpMessageHandler( | ||||
|             (request, _) => | ||||
|             { | ||||
|                 attempts++; | ||||
|                 var response = new HttpResponseMessage(HttpStatusCode.TooManyRequests) | ||||
|                 { | ||||
|                     RequestMessage = request, | ||||
|                     Content = new StringContent("busy") | ||||
|                 }; | ||||
|                 response.Headers.Add("Retry-After", "1"); | ||||
|                 return response; | ||||
|             }, | ||||
|             (request, _) => | ||||
|             { | ||||
|                 attempts++; | ||||
|                 return new HttpResponseMessage(HttpStatusCode.OK) | ||||
|                 { | ||||
|                     RequestMessage = request | ||||
|                 }; | ||||
|             }); | ||||
|  | ||||
|         var httpClient = new HttpClient(handler) | ||||
|         { | ||||
|             BaseAddress = new Uri("https://feedser.example") | ||||
|         }; | ||||
|  | ||||
|         var options = new StellaOpsCliOptions | ||||
|         { | ||||
|             BackendUrl = "https://feedser.example", | ||||
|             ScanUploadAttempts = 3 | ||||
|         }; | ||||
|  | ||||
|         var loggerFactory = LoggerFactory.Create(builder => builder.SetMinimumLevel(LogLevel.Debug)); | ||||
|         var client = new BackendOperationsClient(httpClient, options, loggerFactory.CreateLogger<BackendOperationsClient>()); | ||||
|  | ||||
|         await client.UploadScanResultsAsync(filePath, CancellationToken.None); | ||||
|  | ||||
|         Assert.Equal(2, attempts); | ||||
|     } | ||||
|  | ||||
|     [Fact] | ||||
|     public async Task UploadScanResultsAsync_ThrowsAfterMaxAttempts() | ||||
|     { | ||||
|         using var temp = new TempDirectory(); | ||||
|         var filePath = Path.Combine(temp.Path, "scan.json"); | ||||
|         await File.WriteAllTextAsync(filePath, "{}"); | ||||
|  | ||||
|         var attempts = 0; | ||||
|         var handler = new StubHttpMessageHandler( | ||||
|             (request, _) => | ||||
|             { | ||||
|                 attempts++; | ||||
|                 return new HttpResponseMessage(HttpStatusCode.BadGateway) | ||||
|                 { | ||||
|                     RequestMessage = request, | ||||
|                     Content = new StringContent("bad gateway") | ||||
|                 }; | ||||
|             }); | ||||
|  | ||||
|         var httpClient = new HttpClient(handler) | ||||
|         { | ||||
|             BaseAddress = new Uri("https://feedser.example") | ||||
|         }; | ||||
|  | ||||
|         var options = new StellaOpsCliOptions | ||||
|         { | ||||
|             BackendUrl = "https://feedser.example", | ||||
|             ScanUploadAttempts = 2 | ||||
|         }; | ||||
|  | ||||
|         var loggerFactory = LoggerFactory.Create(builder => builder.SetMinimumLevel(LogLevel.Debug)); | ||||
|         var client = new BackendOperationsClient(httpClient, options, loggerFactory.CreateLogger<BackendOperationsClient>()); | ||||
|  | ||||
|         await Assert.ThrowsAsync<InvalidOperationException>(() => client.UploadScanResultsAsync(filePath, CancellationToken.None)); | ||||
|         Assert.Equal(2, attempts); | ||||
|     } | ||||
|  | ||||
|     [Fact] | ||||
|     public async Task TriggerJobAsync_ReturnsAcceptedResult() | ||||
|     { | ||||
|         var handler = new StubHttpMessageHandler((request, _) => | ||||
|         { | ||||
|             var response = new HttpResponseMessage(HttpStatusCode.Accepted) | ||||
|             { | ||||
|                 RequestMessage = request, | ||||
|                 Content = JsonContent.Create(new JobRunResponse | ||||
|                 { | ||||
|                     RunId = Guid.NewGuid(), | ||||
|                     Status = "queued", | ||||
|                     Kind = "export:json", | ||||
|                     Trigger = "cli", | ||||
|                     CreatedAt = DateTimeOffset.UtcNow | ||||
|                 }) | ||||
|             }; | ||||
|             response.Headers.Location = new Uri("/jobs/export:json/runs/123", UriKind.Relative); | ||||
|             return response; | ||||
|         }); | ||||
|  | ||||
|         var httpClient = new HttpClient(handler) | ||||
|         { | ||||
|             BaseAddress = new Uri("https://feedser.example") | ||||
|         }; | ||||
|  | ||||
|         var options = new StellaOpsCliOptions { BackendUrl = "https://feedser.example" }; | ||||
|         var loggerFactory = LoggerFactory.Create(builder => builder.SetMinimumLevel(LogLevel.Debug)); | ||||
|         var client = new BackendOperationsClient(httpClient, options, loggerFactory.CreateLogger<BackendOperationsClient>()); | ||||
|  | ||||
|         var result = await client.TriggerJobAsync("export:json", new Dictionary<string, object?>(), CancellationToken.None); | ||||
|  | ||||
|         Assert.True(result.Success); | ||||
|         Assert.Equal("Accepted", result.Message); | ||||
|         Assert.Equal("/jobs/export:json/runs/123", result.Location); | ||||
|     } | ||||
|  | ||||
|     [Fact] | ||||
|     public async Task TriggerJobAsync_ReturnsFailureMessage() | ||||
|     { | ||||
|         var handler = new StubHttpMessageHandler((request, _) => | ||||
|         { | ||||
|             var problem = new | ||||
|             { | ||||
|                 title = "Job already running", | ||||
|                 detail = "export job active" | ||||
|             }; | ||||
|  | ||||
|             var response = new HttpResponseMessage(HttpStatusCode.Conflict) | ||||
|             { | ||||
|                 RequestMessage = request, | ||||
|                 Content = JsonContent.Create(problem) | ||||
|             }; | ||||
|             return response; | ||||
|         }); | ||||
|  | ||||
|         var httpClient = new HttpClient(handler) | ||||
|         { | ||||
|             BaseAddress = new Uri("https://feedser.example") | ||||
|         }; | ||||
|  | ||||
|         var options = new StellaOpsCliOptions { BackendUrl = "https://feedser.example" }; | ||||
|         var loggerFactory = LoggerFactory.Create(builder => builder.SetMinimumLevel(LogLevel.Debug)); | ||||
|         var client = new BackendOperationsClient(httpClient, options, loggerFactory.CreateLogger<BackendOperationsClient>()); | ||||
|  | ||||
|         var result = await client.TriggerJobAsync("export:json", new Dictionary<string, object?>(), CancellationToken.None); | ||||
|  | ||||
|         Assert.False(result.Success); | ||||
|         Assert.Contains("Job already running", result.Message); | ||||
|     } | ||||
|  | ||||
|     [Fact] | ||||
|     public async Task TriggerJobAsync_UsesAuthorityTokenWhenConfigured() | ||||
|     { | ||||
|         using var temp = new TempDirectory(); | ||||
|  | ||||
|         var handler = new StubHttpMessageHandler((request, _) => | ||||
|         { | ||||
|             Assert.NotNull(request.Headers.Authorization); | ||||
|             Assert.Equal("Bearer", request.Headers.Authorization!.Scheme); | ||||
|             Assert.Equal("token-123", request.Headers.Authorization.Parameter); | ||||
|  | ||||
|             return new HttpResponseMessage(HttpStatusCode.Accepted) | ||||
|             { | ||||
|                 RequestMessage = request, | ||||
|                 Content = JsonContent.Create(new JobRunResponse | ||||
|                 { | ||||
|                     RunId = Guid.NewGuid(), | ||||
|                     Kind = "test", | ||||
|                     Status = "Pending", | ||||
|                     Trigger = "cli", | ||||
|                     CreatedAt = DateTimeOffset.UtcNow | ||||
|                 }) | ||||
|             }; | ||||
|         }); | ||||
|  | ||||
|         var httpClient = new HttpClient(handler) | ||||
|         { | ||||
|             BaseAddress = new Uri("https://feedser.example") | ||||
|         }; | ||||
|  | ||||
|         var options = new StellaOpsCliOptions | ||||
|         { | ||||
|             BackendUrl = "https://feedser.example", | ||||
|             Authority = | ||||
|             { | ||||
|                 Url = "https://authority.example", | ||||
|                 ClientId = "cli", | ||||
|                 ClientSecret = "secret", | ||||
|                 Scope = "feedser.jobs.trigger", | ||||
|                 TokenCacheDirectory = temp.Path | ||||
|             } | ||||
|         }; | ||||
|  | ||||
|         var tokenClient = new StubTokenClient(); | ||||
|         var loggerFactory = LoggerFactory.Create(builder => builder.SetMinimumLevel(LogLevel.Debug)); | ||||
|         var client = new BackendOperationsClient(httpClient, options, loggerFactory.CreateLogger<BackendOperationsClient>(), tokenClient); | ||||
|  | ||||
|         var result = await client.TriggerJobAsync("test", new Dictionary<string, object?>(), CancellationToken.None); | ||||
|  | ||||
|         Assert.True(result.Success); | ||||
|         Assert.Equal("Accepted", result.Message); | ||||
|         Assert.True(tokenClient.Requests > 0); | ||||
|     } | ||||
|  | ||||
|     private sealed class StubTokenClient : IStellaOpsTokenClient | ||||
|     { | ||||
|         private readonly StellaOpsTokenResult _tokenResult; | ||||
|  | ||||
|         public int Requests { get; private set; } | ||||
|  | ||||
|         public StubTokenClient() | ||||
|         { | ||||
|             _tokenResult = new StellaOpsTokenResult( | ||||
|                 "token-123", | ||||
|                 "Bearer", | ||||
|                 DateTimeOffset.UtcNow.AddMinutes(5), | ||||
|                 new[] { StellaOpsScopes.FeedserJobsTrigger }); | ||||
|         } | ||||
|  | ||||
|         public ValueTask CacheTokenAsync(string key, StellaOpsTokenCacheEntry entry, CancellationToken cancellationToken = default) | ||||
|             => ValueTask.CompletedTask; | ||||
|  | ||||
|         public ValueTask ClearCachedTokenAsync(string key, CancellationToken cancellationToken = default) | ||||
|             => ValueTask.CompletedTask; | ||||
|  | ||||
|         public Task<JsonWebKeySet> GetJsonWebKeySetAsync(CancellationToken cancellationToken = default) | ||||
|             => Task.FromResult(new JsonWebKeySet("{\"keys\":[]}")); | ||||
|  | ||||
|         public ValueTask<StellaOpsTokenCacheEntry?> GetCachedTokenAsync(string key, CancellationToken cancellationToken = default) | ||||
|             => ValueTask.FromResult<StellaOpsTokenCacheEntry?>(null); | ||||
|  | ||||
|         public Task<StellaOpsTokenResult> RequestClientCredentialsTokenAsync(string? scope = null, CancellationToken cancellationToken = default) | ||||
|         { | ||||
|             Requests++; | ||||
|             return Task.FromResult(_tokenResult); | ||||
|         } | ||||
|  | ||||
|         public Task<StellaOpsTokenResult> RequestPasswordTokenAsync(string username, string password, string? scope = null, CancellationToken cancellationToken = default) | ||||
|         { | ||||
|             Requests++; | ||||
|             return Task.FromResult(_tokenResult); | ||||
|         } | ||||
|     } | ||||
| } | ||||
							
								
								
									
										28
									
								
								src/StellaOps.Cli.Tests/StellaOps.Cli.Tests.csproj
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										28
									
								
								src/StellaOps.Cli.Tests/StellaOps.Cli.Tests.csproj
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,28 @@ | ||||
| <Project Sdk="Microsoft.NET.Sdk"> | ||||
|  | ||||
|   <PropertyGroup> | ||||
|     <TargetFramework>net10.0</TargetFramework> | ||||
|     <ImplicitUsings>enable</ImplicitUsings> | ||||
|     <Nullable>enable</Nullable> | ||||
|     <IsPackable>false</IsPackable> | ||||
|  | ||||
|     <!-- To enable Microsoft.Testing.Platform, uncomment the following line. --> | ||||
|     <!-- <UseMicrosoftTestingPlatformRunner>true</UseMicrosoftTestingPlatformRunner> --> | ||||
|     <!-- Note: to use Microsoft.Testing.Platform correctly with dotnet test: --> | ||||
|     <!-- 1. You must add dotnet.config specifying the test runner to be Microsoft.Testing.Platform --> | ||||
|     <!-- 2. You must use .NET 10 SDK or later --> | ||||
|     <!-- For more information, see https://aka.ms/dotnet-test/mtp and https://xunit.net/docs/getting-started/v3/microsoft-testing-platform --> | ||||
|     <!-- To enable code coverage with Microsoft.Testing.Platform, add a package reference to Microsoft.Testing.Extensions.CodeCoverage --> | ||||
|     <!-- https://learn.microsoft.comdotnet/core/testing/microsoft-testing-platform-extensions-code-coverage --> | ||||
|   </PropertyGroup> | ||||
|  | ||||
|   <ItemGroup> | ||||
|     <Using Include="Xunit" /> | ||||
|   </ItemGroup> | ||||
|  | ||||
|   <ItemGroup> | ||||
|     <ProjectReference Include="..\StellaOps.Cli\StellaOps.Cli.csproj" /> | ||||
|     <ProjectReference Include="..\StellaOps.Configuration\StellaOps.Configuration.csproj" /> | ||||
|   </ItemGroup> | ||||
|  | ||||
| </Project> | ||||
							
								
								
									
										55
									
								
								src/StellaOps.Cli.Tests/Testing/TestHelpers.cs
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										55
									
								
								src/StellaOps.Cli.Tests/Testing/TestHelpers.cs
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,55 @@ | ||||
| using System; | ||||
| using System.Collections.Generic; | ||||
| using System.IO; | ||||
| using System.Net.Http; | ||||
| using System.Threading; | ||||
| using System.Threading.Tasks; | ||||
|  | ||||
| namespace StellaOps.Cli.Tests.Testing; | ||||
|  | ||||
| internal sealed class TempDirectory : IDisposable | ||||
| { | ||||
|     public TempDirectory() | ||||
|     { | ||||
|         Path = System.IO.Path.Combine(System.IO.Path.GetTempPath(), $"stellaops-cli-tests-{Guid.NewGuid():N}"); | ||||
|         Directory.CreateDirectory(Path); | ||||
|     } | ||||
|  | ||||
|     public string Path { get; } | ||||
|  | ||||
|     public void Dispose() | ||||
|     { | ||||
|         try | ||||
|         { | ||||
|             if (Directory.Exists(Path)) | ||||
|             { | ||||
|                 Directory.Delete(Path, recursive: true); | ||||
|             } | ||||
|         } | ||||
|         catch | ||||
|         { | ||||
|             // ignored | ||||
|         } | ||||
|     } | ||||
| } | ||||
|  | ||||
| internal sealed class StubHttpMessageHandler : HttpMessageHandler | ||||
| { | ||||
|     private readonly Queue<Func<HttpRequestMessage, CancellationToken, HttpResponseMessage>> _responses; | ||||
|  | ||||
|     public StubHttpMessageHandler(params Func<HttpRequestMessage, CancellationToken, HttpResponseMessage>[] handlers) | ||||
|     { | ||||
|         if (handlers is null || handlers.Length == 0) | ||||
|         { | ||||
|             throw new ArgumentException("At least one handler must be provided.", nameof(handlers)); | ||||
|         } | ||||
|  | ||||
|         _responses = new Queue<Func<HttpRequestMessage, CancellationToken, HttpResponseMessage>>(handlers); | ||||
|     } | ||||
|  | ||||
|     protected override Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) | ||||
|     { | ||||
|         var factory = _responses.Count > 1 ? _responses.Dequeue() : _responses.Peek(); | ||||
|         return Task.FromResult(factory(request, cancellationToken)); | ||||
|     } | ||||
| } | ||||
							
								
								
									
										10
									
								
								src/StellaOps.Cli.Tests/UnitTest1.cs
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										10
									
								
								src/StellaOps.Cli.Tests/UnitTest1.cs
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,10 @@ | ||||
| namespace StellaOps.Cli.Tests; | ||||
|  | ||||
| public class UnitTest1 | ||||
| { | ||||
|     [Fact] | ||||
|     public void Test1() | ||||
|     { | ||||
|  | ||||
|     } | ||||
| } | ||||
							
								
								
									
										3
									
								
								src/StellaOps.Cli.Tests/xunit.runner.json
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										3
									
								
								src/StellaOps.Cli.Tests/xunit.runner.json
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,3 @@ | ||||
| { | ||||
|     "$schema": "https://xunit.net/schema/current/xunit.runner.schema.json" | ||||
| } | ||||
		Reference in New Issue
	
	Block a user