using System.Security.Claims; using System.Text; using System.Text.Json; using D3ROVoice.Api.Controllers; using D3ROVoice.Api.Dtos; using D3ROVoice.Api.Services; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc; using Microsoft.Extensions.Configuration; using Xunit; namespace D3ROVoice.Api.Tests; public sealed class SttControllerSecurityTests { private const string GatewayToken = "fixture-internal-gateway-token-32-bytes-minimum"; [Fact] public void Controller_RequiresJwtAuthorization() { Assert.NotNull(Attribute.GetCustomAttribute(typeof(SttController), typeof(AuthorizeAttribute))); } [Theory] [InlineData(null, false)] [InlineData("42", true)] [InlineData("not-an-integer", true)] public async Task Transcribe_AlwaysRequiresQuotaOwningEdgeGatewayAndNeverCallsProvider( string? userId, bool authenticated) { var service = new RecordingSttService(); var controller = CreateController(service, userId, isAuthenticated: authenticated); var result = Assert.IsType(await controller.Transcribe()); Assert.Equal(StatusCodes.Status410Gone, result.StatusCode); Assert.Contains("stt_edge_gateway_required", JsonSerializer.Serialize(result.Value), StringComparison.Ordinal); Assert.Equal(0, service.TranscribeCalls); } [Theory] [InlineData(null)] [InlineData("wrong-token")] public async Task InternalGateway_RejectsMissingOrWrongTokenWithoutProviderCall(string? suppliedToken) { var service = new RecordingSttService(); var controller = CreateController(service, null, isAuthenticated: false, suppliedGatewayToken: suppliedToken); var result = Assert.IsType(await controller.TranscribeFromQuotaGateway()); Assert.Equal(StatusCodes.Status401Unauthorized, result.StatusCode); Assert.Equal(0, service.TranscribeCalls); } [Fact] public async Task InternalGateway_FailsClosedWhenServerSecretIsTooShort() { var service = new RecordingSttService(); var controller = CreateController( service, null, isAuthenticated: false, configuredGatewayToken: "short", suppliedGatewayToken: "short"); var result = Assert.IsType(await controller.TranscribeFromQuotaGateway()); Assert.Equal(StatusCodes.Status503ServiceUnavailable, result.StatusCode); Assert.Equal(0, service.TranscribeCalls); } [Fact] public async Task InternalGateway_ValidTokenInvokesProviderWithoutLegacyUsageWrite() { var service = new RecordingSttService(); var controller = CreateController( service, null, isAuthenticated: false, suppliedGatewayToken: GatewayToken, includeAudioForm: true); var result = Assert.IsType(await controller.TranscribeFromQuotaGateway()); Assert.Equal(StatusCodes.Status200OK, result.StatusCode); Assert.Equal(1, service.TranscribeCalls); Assert.False(service.LastRecordUsage); Assert.Equal(0, service.LastUserId); Assert.Equal("edge-internal", service.LastUserEmail); } private static SttController CreateController( RecordingSttService service, string? userId, bool isAuthenticated, string? email = null, string configuredGatewayToken = GatewayToken, string? suppliedGatewayToken = null, bool includeAudioForm = false) { var claims = new List(); if (userId != null) claims.Add(new Claim(ClaimTypes.NameIdentifier, userId)); if (email != null) claims.Add(new Claim(ClaimTypes.Email, email)); var identity = new ClaimsIdentity(claims, isAuthenticated ? "test-auth" : null); var context = new DefaultHttpContext { User = new ClaimsPrincipal(identity) }; if (includeAudioForm) { context.Request.ContentType = "multipart/form-data; boundary=fixture"; var audioStream = new MemoryStream(new byte[] { 1, 2, 3, 4 }); var files = new FormFileCollection { new FormFile(audioStream, 0, audioStream.Length, "file", "fixture.wav") { Headers = new HeaderDictionary(), ContentType = "audio/wav", } }; context.Request.Form = new FormCollection( new Dictionary(), files); } else { context.Request.ContentType = "application/json"; context.Request.Body = new MemoryStream(Encoding.UTF8.GetBytes("{\"audioBase64\":\"AQID\"}")); } if (suppliedGatewayToken != null) context.Request.Headers["X-D3RO-STT-Gateway-Token"] = suppliedGatewayToken; var configuration = new ConfigurationBuilder() .AddInMemoryCollection(new Dictionary { ["D3RO_API_TOKEN"] = configuredGatewayToken, }) .Build(); return new SttController(service, configuration) { ControllerContext = new ControllerContext { HttpContext = context } }; } private sealed class RecordingSttService : ISttProxyService { public SttProviderUnavailableException? Exception { get; init; } public int TranscribeCalls { get; private set; } public int LastUserId { get; private set; } public string? LastUserEmail { get; private set; } public bool LastRecordUsage { get; private set; } = true; public Task TranscribeAsync( int userId, string userEmail, SttTranscribeRequest request, byte[]? audioBytes = null, string? contentType = null, string? fileName = null, bool recordUsage = true) { TranscribeCalls++; LastUserId = userId; LastUserEmail = userEmail; LastRecordUsage = recordUsage; if (Exception != null) throw Exception; return Task.FromResult(new SttTranscribeResponse( "real transcript", 0.99, "ko", 1, "test", "test-model", 1, 0)); } public Task TestEndpointAsync(int endpointId, string? testApiKey = null, string? testEndpointUrl = null) => throw new NotSupportedException(); public Task> GetAllEndpointsAsync() => Task.FromResult(new List()); public Task CreateEndpointAsync(CreateSttEndpointDto dto) => throw new NotSupportedException(); public Task UpdateEndpointAsync(int id, UpdateSttEndpointDto dto) => throw new NotSupportedException(); public Task DeleteEndpointAsync(int id) => throw new NotSupportedException(); public Task SetDefaultEndpointAsync(int id) => throw new NotSupportedException(); public Task GetUsageReportAsync() => throw new NotSupportedException(); } }