using System.Net; using System.Text; using D3ROVoice.Api.Data; using D3ROVoice.Api.Dtos; using D3ROVoice.Api.Services; using Microsoft.Data.Sqlite; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Logging.Abstractions; using Xunit; namespace D3ROVoice.Api.Tests; public sealed class SttFailClosedTests { [Fact] public async Task MissingProviderCredentials_FailsUnavailableWithoutUsage() { await using var fixture = await SttFixture.CreateAsync( apiKey: string.Empty, _ => throw new InvalidOperationException("HTTP should not be called")); var exception = await Assert.ThrowsAsync( () => fixture.Service.TranscribeAsync( 7, "user@example.test", new SttTranscribeRequest(Language: "ko"), new byte[] { 1, 2, 3 }, "audio/webm", "recording.webm")); Assert.False(exception.ProviderAttempted); Assert.Equal(0, fixture.Handler.CallCount); Assert.Empty(await fixture.Db.SttUsageLogs.ToListAsync()); } [Fact] public async Task UpstreamFailure_FailsBadGatewayWithoutLeakingOrRecordingUsage() { const string sensitiveBody = "secret-key-value original transcript text"; await using var fixture = await SttFixture.CreateAsync( apiKey: "configured-key", _ => new HttpResponseMessage(HttpStatusCode.InternalServerError) { Content = new StringContent(sensitiveBody) }); var exception = await Assert.ThrowsAsync( () => fixture.Service.TranscribeAsync( 9, "user@example.test", new SttTranscribeRequest(Language: "ko"), new byte[] { 1, 2, 3 }, "audio/webm", "recording.webm")); Assert.True(exception.ProviderAttempted); Assert.DoesNotContain(sensitiveBody, exception.Message, StringComparison.Ordinal); Assert.Empty(await fixture.Db.SttUsageLogs.ToListAsync()); var error = Assert.Single(await fixture.Db.ErrorLogs.ToListAsync()); Assert.DoesNotContain(sensitiveBody, error.Message, StringComparison.Ordinal); Assert.Null(error.Endpoint); Assert.Null(error.StackTrace); } [Fact] public async Task RealProviderSuccess_RecordsUsageAndReturnsTranscript() { await using var fixture = await SttFixture.CreateAsync( apiKey: "configured-key", _ => new HttpResponseMessage(HttpStatusCode.OK) { Content = new StringContent( "{\"text\":\"real transcript\",\"duration\":1.25,\"language\":\"ko\"}", Encoding.UTF8, "application/json") }); var result = await fixture.Service.TranscribeAsync( 11, "user@example.test", new SttTranscribeRequest(Language: "ko"), new byte[] { 1, 2, 3 }, "audio/webm", "recording.webm"); Assert.Equal("real transcript", result.Text); Assert.Equal("groq", result.Provider); var usage = Assert.Single(await fixture.Db.SttUsageLogs.ToListAsync()); Assert.Equal(11, usage.UserId); Assert.Equal(200, usage.StatusCode); } [Fact] public async Task GoogleApiKey_IsSentInHeaderAndNeverInRequestUrl() { const string apiKey = "sensitive-google-key"; await using var fixture = await SttFixture.CreateAsync( apiKey, _ => new HttpResponseMessage(HttpStatusCode.OK) { Content = new StringContent( "{\"candidates\":[{\"content\":{\"parts\":[{\"text\":\"real transcript\"}]}}]}", Encoding.UTF8, "application/json") }, providerType: "google", endpointUrl: "https://generativelanguage.googleapis.com/v1beta/models/gemini:generateContent", modelId: "gemini-test"); var result = await fixture.Service.TranscribeAsync( 12, "user@example.test", new SttTranscribeRequest(Language: "ko"), new byte[] { 1, 2, 3 }, "audio/webm", "recording.webm"); Assert.Equal("real transcript", result.Text); Assert.Equal(apiKey, fixture.Handler.LastGoogleApiKey); Assert.DoesNotContain(apiKey, fixture.Handler.LastRequestUri, StringComparison.Ordinal); } private sealed class SttFixture : IAsyncDisposable { private readonly SqliteConnection _connection; private SttFixture( SqliteConnection connection, AppDbContext db, RecordingHandler handler, SttProxyService service) { _connection = connection; Db = db; Handler = handler; Service = service; } public AppDbContext Db { get; } public RecordingHandler Handler { get; } public SttProxyService Service { get; } public static async Task CreateAsync( string apiKey, Func responseFactory, string providerType = "groq", string endpointUrl = "https://provider.example.test/transcriptions", string modelId = "whisper-test") { var connection = new SqliteConnection("Data Source=:memory:"); await connection.OpenAsync(); var options = new DbContextOptionsBuilder() .UseSqlite(connection) .Options; var db = new AppDbContext(options); await db.Database.EnsureCreatedAsync(); db.SttProviderEndpoints.Add(new SttProviderEndpoint { Name = "Test provider", ProviderType = providerType, EndpointUrl = endpointUrl, ApiKey = apiKey, ModelId = modelId, Method = "multipart", Language = "ko", IsDefault = true, IsActive = true, FallbackPriority = 1 }); await db.SaveChangesAsync(); var handler = new RecordingHandler(responseFactory); var factory = new StubHttpClientFactory(handler); var service = new SttProxyService(db, factory, NullLogger.Instance); return new SttFixture(connection, db, handler, service); } public async ValueTask DisposeAsync() { await Db.DisposeAsync(); await _connection.DisposeAsync(); } } private sealed class StubHttpClientFactory : IHttpClientFactory { private readonly RecordingHandler _handler; public StubHttpClientFactory(RecordingHandler handler) { _handler = handler; } public HttpClient CreateClient(string name) => new(_handler, disposeHandler: false); } public sealed class RecordingHandler : HttpMessageHandler { private readonly Func _responseFactory; public RecordingHandler(Func responseFactory) { _responseFactory = responseFactory; } public int CallCount { get; private set; } public string LastRequestUri { get; private set; } = string.Empty; public string? LastGoogleApiKey { get; private set; } protected override Task SendAsync( HttpRequestMessage request, CancellationToken cancellationToken) { CallCount++; LastRequestUri = request.RequestUri?.ToString() ?? string.Empty; LastGoogleApiKey = request.Headers.TryGetValues("X-Goog-Api-Key", out var values) ? values.SingleOrDefault() : null; return Task.FromResult(_responseFactory(request)); } } }