@@ -2,6 +2,7 @@ namespace VastAI.NET.Tests;
22
33using System . Net ;
44using System . Text . Json ;
5+ using Microsoft . Extensions . Logging ;
56using Microsoft . Extensions . Logging . Abstractions ;
67using Microsoft . Extensions . Options ;
78using Models ;
@@ -238,6 +239,26 @@ public void Create_WithMissingRuntimeApiKey_ThrowsConfigurationException()
238239 Assert . Contains ( "Vast API key is required" , exception . Message ) ;
239240 }
240241
242+ /// <summary>Failed Vast responses must not copy the configured bearer token into exceptions or logs.</summary>
243+ [ Fact ]
244+ public async Task GetInstancesAsync_WhenRequestFails_DoesNotLeakApiKeyInExceptionOrLogs ( )
245+ {
246+ const string apiKey = "live-secret-value-that-must-not-appear" ;
247+ var handler = new SequenceHandler (
248+ new HttpResponseMessage ( HttpStatusCode . Unauthorized ) { Content = new StringContent ( """{"error":"unauthorized"}""" ) } ) ;
249+ var logger = new CapturingLogger < VastApiClient > ( ) ;
250+ var client = new VastApiClient (
251+ new HttpClient ( handler ) ,
252+ Options . Create ( new VastAIOptions { ApiKey = apiKey , ApiBaseUri = new Uri ( "https://console.vast.ai/" ) } ) ,
253+ logger ) ;
254+
255+ var exception = await Assert . ThrowsAsync < Exceptions . VastAIOperationException > ( ( ) => client . GetInstancesAsync (
256+ TestContext . Current . CancellationToken ) ) ;
257+
258+ Assert . DoesNotContain ( apiKey , exception . ToString ( ) , StringComparison . Ordinal ) ;
259+ Assert . DoesNotContain ( apiKey , string . Join ( Environment . NewLine , logger . Messages ) , StringComparison . Ordinal ) ;
260+ }
261+
241262 /// <summary>Creates a client with a fake handler and deterministic options.</summary>
242263 private static VastApiClient CreateClient ( HttpMessageHandler handler )
243264 {
@@ -284,4 +305,36 @@ protected override Task<HttpResponseMessage> SendAsync(HttpRequestMessage reques
284305 : _responses . Dequeue ( ) ) ;
285306 }
286307 }
308+
309+ /// <summary>Captures rendered log messages without writing them to test output.</summary>
310+ private sealed class CapturingLogger < T > : ILogger < T >
311+ {
312+ public List < string > Messages { get ; } = [ ] ;
313+
314+ public IDisposable ? BeginScope < TState > ( TState state ) where TState : notnull => NullScope . Instance ;
315+
316+ public bool IsEnabled ( LogLevel logLevel ) => true ;
317+
318+ public void Log < TState > (
319+ LogLevel logLevel ,
320+ EventId eventId ,
321+ TState state ,
322+ Exception ? exception ,
323+ Func < TState , Exception ? , string > formatter )
324+ {
325+ Messages . Add ( formatter ( state , exception ) ) ;
326+ if ( exception is not null )
327+ Messages . Add ( exception . ToString ( ) ) ;
328+ }
329+ }
330+
331+ /// <summary>Reusable no-op logging scope.</summary>
332+ private sealed class NullScope : IDisposable
333+ {
334+ public static readonly NullScope Instance = new ( ) ;
335+
336+ public void Dispose ( )
337+ {
338+ }
339+ }
287340}
0 commit comments