223 lines
8 KiB
C#
223 lines
8 KiB
C#
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<SttProviderUnavailableException>(
|
|
() => 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<SttProviderUnavailableException>(
|
|
() => 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<SttFixture> CreateAsync(
|
|
string apiKey,
|
|
Func<HttpRequestMessage, HttpResponseMessage> 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<AppDbContext>()
|
|
.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<SttProxyService>.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<HttpRequestMessage, HttpResponseMessage> _responseFactory;
|
|
|
|
public RecordingHandler(Func<HttpRequestMessage, HttpResponseMessage> 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<HttpResponseMessage> 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));
|
|
}
|
|
}
|
|
}
|