feat(release): prepare 1.1.0 candidate

This commit is contained in:
Yun Chan 2026-08-29 18:33:45 +09:00
parent 5a34f66981
commit 5205dcdfa9
736 changed files with 115667 additions and 12203 deletions

View file

@ -0,0 +1,119 @@
using System.IdentityModel.Tokens.Jwt;
using System.Net;
using System.Net.Http.Headers;
using System.Security.Claims;
using System.Text;
using Microsoft.AspNetCore.Hosting;
using Microsoft.AspNetCore.Mvc.Testing;
using Microsoft.Data.Sqlite;
using Microsoft.Extensions.Logging;
using Microsoft.IdentityModel.Tokens;
using Xunit;
namespace D3ROVoice.Api.Tests;
[CollectionDefinition("Api server integration", DisableParallelization = true)]
public sealed class ApiServerIntegrationCollection;
[Collection("Api server integration")]
public sealed class AdminAuthorizationE2ETests : IClassFixture<AdminAuthorizationE2ETests.ApiFactory>
{
private const string Secret = "admin-e2e-jwt-secret-0123456789-abcdef";
private const string Issuer = "https://admin-e2e.test";
private const string Audience = "d3ro-admin-e2e";
private readonly ApiFactory _factory;
public AdminAuthorizationE2ETests(ApiFactory factory)
{
_factory = factory;
}
[Fact]
public async Task AdminReadEndpointsRejectAnonymousAndNonManager()
{
using var client = _factory.CreateClient();
Assert.Equal(HttpStatusCode.Unauthorized, (await client.GetAsync("/api/admin/stats")).StatusCode);
client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", Token("User"));
Assert.Equal(HttpStatusCode.Forbidden, (await client.GetAsync("/api/admin/stats")).StatusCode);
client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", Token("manager"));
Assert.Equal(HttpStatusCode.OK, (await client.GetAsync("/api/admin/stats")).StatusCode);
}
[Fact]
public async Task AdminMutationRejectsManagerAndAcceptsNormalizedSuperAdmin()
{
using var client = _factory.CreateClient();
using var emptyPayload = new StringContent("{}", Encoding.UTF8, "application/json");
client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", Token("Manager"));
Assert.Equal(HttpStatusCode.Forbidden,
(await client.PostAsync("/api/admin/endpoints", emptyPayload)).StatusCode);
client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", Token("super_admin"));
using var secondPayload = new StringContent("{}", Encoding.UTF8, "application/json");
Assert.Equal(HttpStatusCode.BadRequest,
(await client.PostAsync("/api/admin/endpoints", secondPayload)).StatusCode);
}
private static string Token(string role)
{
var credentials = new SigningCredentials(
new SymmetricSecurityKey(Encoding.UTF8.GetBytes(Secret)),
SecurityAlgorithms.HmacSha256);
var token = new JwtSecurityToken(
issuer: Issuer,
audience: Audience,
claims:
[
new Claim(ClaimTypes.NameIdentifier, "1"),
new Claim(ClaimTypes.Email, "admin@example.com"),
new Claim(ClaimTypes.Role, role)
],
expires: DateTime.UtcNow.AddMinutes(5),
signingCredentials: credentials);
return new JwtSecurityTokenHandler().WriteToken(token);
}
public sealed class ApiFactory : WebApplicationFactory<Program>
{
private readonly string _databasePath = Path.Combine(
Path.GetTempPath(), $"d3ro-admin-auth-e2e-{Guid.NewGuid():N}.db");
private readonly Dictionary<string, string?> _previousEnvironment = new();
public ApiFactory()
{
SetEnvironment("JWT_SECRET", Secret);
SetEnvironment("JWT_ISSUER", Issuer);
SetEnvironment("JWT_AUDIENCE", Audience);
SetEnvironment("D3RO_API_TOKEN", "fixture-internal-gateway-token-32-bytes-minimum");
SetEnvironment("DB_PATH", _databasePath);
SetEnvironment("CORS_ALLOWED_ORIGINS", "http://localhost:3001");
SetEnvironment("ALLOWED_HOSTS", "localhost;127.0.0.1");
}
protected override void ConfigureWebHost(IWebHostBuilder builder)
{
builder.UseEnvironment("Development");
builder.ConfigureLogging(logging => logging.ClearProviders());
}
protected override void Dispose(bool disposing)
{
base.Dispose(disposing);
SqliteConnection.ClearAllPools();
if (File.Exists(_databasePath)) File.Delete(_databasePath);
foreach (var (name, value) in _previousEnvironment)
{
Environment.SetEnvironmentVariable(name, value);
}
}
private void SetEnvironment(string name, string value)
{
_previousEnvironment[name] = Environment.GetEnvironmentVariable(name);
Environment.SetEnvironmentVariable(name, value);
}
}
}

View file

@ -0,0 +1,97 @@
using D3ROVoice.Api.Data;
using D3ROVoice.Api.Services;
using Microsoft.EntityFrameworkCore;
using Xunit;
namespace D3ROVoice.Api.Tests;
public sealed class AdminOperationServiceTests
{
[Fact]
public async Task MutationIsAtomicAuditedAndIdempotent()
{
var options = new DbContextOptionsBuilder<AppDbContext>()
.UseSqlite("Data Source=:memory:")
.Options;
await using var db = new AppDbContext(options);
await db.Database.OpenConnectionAsync();
await db.Database.EnsureCreatedAsync();
var service = new AdminOperationService(db);
var idempotencyKey = Guid.NewGuid().ToString("D");
var mutationCalls = 0;
async Task<object> Mutate()
{
mutationCalls += 1;
var endpoint = new ServiceModelEndpoint
{
ModelId = "real-model",
ModelName = "Real Model",
Provider = "Custom",
EndpointUrl = "https://models.example.com/v1",
ApiKey = "secret-never-in-response"
};
db.ModelEndpoints.Add(endpoint);
await db.SaveChangesAsync();
return new { endpoint.Id, endpoint.ModelId };
}
var first = await service.ExecuteAsync(
"ADMIN@EXAMPLE.COM", "model_endpoint.create", idempotencyKey,
new { modelId = "real-model" }, "model_endpoint", _ => "real-model",
"approved provider setup", () => Task.FromResult<object?>(null), Mutate);
var replay = await service.ExecuteAsync(
"admin@example.com", "model_endpoint.create", idempotencyKey,
new { modelId = "real-model" }, "model_endpoint", _ => "real-model",
"approved provider setup", () => Task.FromResult<object?>(null), Mutate);
Assert.Equal(1, mutationCalls);
Assert.Equal(first.GetRawText(), replay.GetRawText());
Assert.Equal(1, await db.ModelEndpoints.CountAsync());
Assert.Equal(1, await db.AdminOperationRequests.CountAsync());
var audit = await db.AdminAuditEntries.SingleAsync();
Assert.Equal("admin@example.com", audit.ActorEmail);
Assert.Equal("model_endpoint.create", audit.Action);
Assert.Equal(idempotencyKey, audit.IdempotencyKey);
await Assert.ThrowsAsync<AdminOperationException>(() => service.ExecuteAsync(
"admin@example.com", "model_endpoint.create", idempotencyKey,
new { modelId = "different-model" }, "model_endpoint", _ => "different-model",
"different request", () => Task.FromResult<object?>(null), Mutate));
}
[Fact]
public async Task FailedMutationRollsBackDomainAndAuditWrites()
{
var options = new DbContextOptionsBuilder<AppDbContext>()
.UseSqlite("Data Source=:memory:")
.Options;
await using var db = new AppDbContext(options);
await db.Database.OpenConnectionAsync();
await db.Database.EnsureCreatedAsync();
var service = new AdminOperationService(db);
async Task<object> FailingMutation()
{
db.ModelEndpoints.Add(new ServiceModelEndpoint
{
ModelId = "rollback-model",
ModelName = "Rollback Model",
Provider = "Custom",
EndpointUrl = "https://models.example.com/v1"
});
await db.SaveChangesAsync();
throw new InvalidOperationException("simulated persistence failure");
}
await Assert.ThrowsAsync<InvalidOperationException>(() => service.ExecuteAsync(
"admin@example.com", "model_endpoint.create", Guid.NewGuid().ToString("D"),
new { modelId = "rollback-model" }, "model_endpoint", _ => "rollback-model",
"rollback verification", () => Task.FromResult<object?>(null), FailingMutation));
db.ChangeTracker.Clear();
Assert.Empty(await db.ModelEndpoints.ToListAsync());
Assert.Empty(await db.AdminOperationRequests.ToListAsync());
Assert.Empty(await db.AdminAuditEntries.ToListAsync());
}
}

View file

@ -0,0 +1,91 @@
using D3ROVoice.Api.Controllers;
using D3ROVoice.Api.Dtos;
using D3ROVoice.Api.Services;
using Microsoft.AspNetCore.Mvc;
using Microsoft.Extensions.Configuration;
using Xunit;
namespace D3ROVoice.Api.Tests;
public sealed class AuthBootstrapControllerTests
{
private static readonly RegisterDto Request = new(
"operator@example.test",
"test-only-password-1234");
[Theory]
[InlineData(null)]
[InlineData("")]
[InlineData("too-short")]
public async Task RegisterIsDisabledWithoutAStrongConfiguredBootstrapToken(string? configuredToken)
{
var service = new RecordingAuthService();
var configuration = Configuration(configuredToken);
var controller = new AuthController(service, configuration);
var result = await controller.Register(Request, configuredToken);
var unavailable = Assert.IsType<ObjectResult>(result);
Assert.Equal(503, unavailable.StatusCode);
Assert.Equal(0, service.RegisterCalls);
}
[Fact]
public async Task RegisterRejectsAnIncorrectBootstrapTokenWithoutCreatingAUser()
{
var service = new RecordingAuthService();
var configuration = Configuration("test-bootstrap-token-0123456789-abcdef");
var controller = new AuthController(service, configuration);
var result = await controller.Register(
Request,
"different-test-token-0123456789-abcdef");
Assert.IsType<UnauthorizedObjectResult>(result);
Assert.Equal(0, service.RegisterCalls);
}
[Fact]
public async Task RegisterAcceptsTheExactStrongBootstrapTokenOnce()
{
const string bootstrapToken = "test-bootstrap-token-0123456789-abcdef";
var service = new RecordingAuthService();
var controller = new AuthController(service, Configuration(bootstrapToken));
var result = await controller.Register(Request, bootstrapToken);
Assert.IsType<OkObjectResult>(result);
Assert.Equal(1, service.RegisterCalls);
}
private static IConfiguration Configuration(string? bootstrapToken)
{
var values = new Dictionary<string, string?>();
if (bootstrapToken is not null)
{
values["ADMIN_BOOTSTRAP_TOKEN"] = bootstrapToken;
}
return new ConfigurationBuilder().AddInMemoryCollection(values).Build();
}
private sealed class RecordingAuthService : IAuthService
{
public int RegisterCalls { get; private set; }
public Task<AuthResponseDto> RegisterAsync(RegisterDto dto)
{
RegisterCalls += 1;
return Task.FromResult(new AuthResponseDto(
"test-token",
dto.Email,
"SuperAdmin",
DateTime.UtcNow.AddMinutes(5)));
}
public Task<AuthResponseDto> LoginAsync(LoginDto dto) =>
throw new NotSupportedException();
public Task<UserInfoDto?> GetUserByEmailAsync(string email) =>
throw new NotSupportedException();
}
}

View file

@ -0,0 +1,118 @@
using D3ROVoice.Api.Data;
using D3ROVoice.Api.Dtos;
using D3ROVoice.Api.Services;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Configuration;
using Xunit;
namespace D3ROVoice.Api.Tests;
public sealed class AuthSecurityTests
{
private static IConfiguration TestConfiguration() => new ConfigurationBuilder()
.AddInMemoryCollection(new Dictionary<string, string?>
{
["JWT_SECRET"] = "test-only-jwt-secret-0123456789-abcdef",
["JWT_ISSUER"] = "https://issuer.test",
["JWT_AUDIENCE"] = "d3ro-admin-test"
})
.Build();
[Fact]
public async Task BootstrapStoresPbkdf2HashAndRejectsASecondAdministrator()
{
var options = new DbContextOptionsBuilder<AppDbContext>()
.UseSqlite("Data Source=:memory:")
.Options;
await using var db = new AppDbContext(options);
await db.Database.OpenConnectionAsync();
await db.Database.EnsureCreatedAsync();
var service = new AuthService(db, TestConfiguration());
var created = await service.RegisterAsync(
new RegisterDto("ADMIN@EXAMPLE.COM", "correct-horse-battery-staple"));
var stored = await db.Users.SingleAsync();
Assert.Equal("admin@example.com", stored.Email);
Assert.Equal("SuperAdmin", stored.Role);
Assert.StartsWith("AQAAAA", stored.PasswordHash);
Assert.DoesNotContain("correct-horse", stored.PasswordHash, StringComparison.Ordinal);
Assert.Equal("SuperAdmin", created.Role);
Assert.True(created.ExpiresAt > DateTime.UtcNow.AddHours(7));
await Assert.ThrowsAsync<InvalidOperationException>(() => service.RegisterAsync(
new RegisterDto("second@example.com", "another-correct-password")));
}
[Fact]
public async Task LoginRejectsWrongPasswordAndReusesNoLegacyHashScheme()
{
var options = new DbContextOptionsBuilder<AppDbContext>()
.UseSqlite("Data Source=:memory:")
.Options;
await using var db = new AppDbContext(options);
await db.Database.OpenConnectionAsync();
await db.Database.EnsureCreatedAsync();
var service = new AuthService(db, TestConfiguration());
await service.RegisterAsync(
new RegisterDto("admin@example.com", "correct-horse-battery-staple"));
await Assert.ThrowsAsync<UnauthorizedAccessException>(() => service.LoginAsync(
new LoginDto("admin@example.com", "wrong-password-value")));
await Assert.ThrowsAsync<UnauthorizedAccessException>(() => service.LoginAsync(
new LoginDto("missing@example.com", "wrong-password-value")));
var login = await service.LoginAsync(
new LoginDto("ADMIN@example.com", "correct-horse-battery-staple"));
Assert.Equal("admin@example.com", login.Email);
Assert.False(string.IsNullOrWhiteSpace(login.Token));
}
[Fact]
public async Task ConcurrentBootstrapAllowsExactlyOneAdministrator()
{
var dbPath = Path.Combine(
Path.GetTempPath(),
$"d3ro-auth-security-{Guid.NewGuid():N}.db");
var options = new DbContextOptionsBuilder<AppDbContext>()
.UseSqlite($"Data Source={dbPath};Pooling=False")
.Options;
try
{
await using (var setup = new AppDbContext(options))
{
await setup.Database.EnsureCreatedAsync();
}
async Task<bool> TryBootstrapAsync(int index)
{
await using var db = new AppDbContext(options);
var service = new AuthService(db, TestConfiguration());
try
{
await service.RegisterAsync(new RegisterDto(
$"admin-{index}@example.com",
$"correct-horse-battery-{index}-staple"));
return true;
}
catch (InvalidOperationException)
{
return false;
}
}
var outcomes = await Task.WhenAll(TryBootstrapAsync(1), TryBootstrapAsync(2));
Assert.Single(outcomes, result => result);
await using (var verification = new AppDbContext(options))
{
Assert.Equal(1, await verification.Users.CountAsync(user => user.IsActive));
}
}
finally
{
if (File.Exists(dbPath)) File.Delete(dbPath);
}
}
}

View file

@ -0,0 +1,25 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<IsPackable>false</IsPackable>
<IsTestProject>true</IsTestProject>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.11.1" />
<PackageReference Include="Microsoft.AspNetCore.Mvc.Testing" Version="10.0.10" />
<PackageReference Include="xunit" Version="2.9.2" />
<PackageReference Include="xunit.runner.visualstudio" Version="2.8.2">
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
<PrivateAssets>all</PrivateAssets>
</PackageReference>
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\api-server\D3ROVoice.Api.csproj" />
</ItemGroup>
</Project>

View file

@ -0,0 +1,191 @@
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<ObjectResult>(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<UnauthorizedObjectResult>(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<ObjectResult>(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<OkObjectResult>(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<Claim>();
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<string, Microsoft.Extensions.Primitives.StringValues>(),
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<string, string?>
{
["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<SttTranscribeResponse> 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<SttTestResultDto> TestEndpointAsync(int endpointId, string? testApiKey = null, string? testEndpointUrl = null) =>
throw new NotSupportedException();
public Task<List<SttProviderEndpointDto>> GetAllEndpointsAsync() =>
Task.FromResult(new List<SttProviderEndpointDto>());
public Task<SttProviderEndpointDto> CreateEndpointAsync(CreateSttEndpointDto dto) =>
throw new NotSupportedException();
public Task<SttProviderEndpointDto> UpdateEndpointAsync(int id, UpdateSttEndpointDto dto) =>
throw new NotSupportedException();
public Task<bool> DeleteEndpointAsync(int id) => throw new NotSupportedException();
public Task<bool> SetDefaultEndpointAsync(int id) => throw new NotSupportedException();
public Task<SttUsageReportDto> GetUsageReportAsync() => throw new NotSupportedException();
}
}

View file

@ -0,0 +1,223 @@
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));
}
}
}

View file

@ -0,0 +1,100 @@
using System.IdentityModel.Tokens.Jwt;
using System.Net;
using System.Net.Http.Headers;
using System.Security.Claims;
using System.Text;
using Microsoft.IdentityModel.Tokens;
using Xunit;
namespace D3ROVoice.Api.Tests;
[Collection("Api server integration")]
public sealed class SttGatewayAuthorizationE2ETests : IClassFixture<AdminAuthorizationE2ETests.ApiFactory>
{
private const string Secret = "admin-e2e-jwt-secret-0123456789-abcdef";
private const string Issuer = "https://admin-e2e.test";
private const string Audience = "d3ro-admin-e2e";
private const string GatewayToken = "fixture-internal-gateway-token-32-bytes-minimum";
private readonly AdminAuthorizationE2ETests.ApiFactory _factory;
public SttGatewayAuthorizationE2ETests(AdminAuthorizationE2ETests.ApiFactory factory)
{
_factory = factory;
}
[Fact]
public async Task UserEndpointRequiresJwtThenStillRefusesQuotaBypass()
{
using var client = _factory.CreateClient();
using var anonymousBody = JsonBody();
Assert.Equal(HttpStatusCode.Unauthorized,
(await client.PostAsync("/api/stt/transcribe", anonymousBody)).StatusCode);
client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", UserToken());
using var authenticatedBody = JsonBody();
var response = await client.PostAsync("/api/stt/transcribe", authenticatedBody);
Assert.Equal(HttpStatusCode.Gone, response.StatusCode);
Assert.Contains("stt_edge_gateway_required", await response.Content.ReadAsStringAsync(), StringComparison.Ordinal);
}
[Theory]
[InlineData(null)]
[InlineData("one-character-off")]
public async Task InternalEndpointRejectsMissingOrWrongGatewaySecret(string? suppliedToken)
{
using var client = _factory.CreateClient();
if (suppliedToken != null)
client.DefaultRequestHeaders.Add("X-D3RO-STT-Gateway-Token", suppliedToken);
using var body = AudioBody();
var response = await client.PostAsync("/api/stt/internal/transcribe", body);
Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode);
Assert.Contains("stt_gateway_unauthorized", await response.Content.ReadAsStringAsync(), StringComparison.Ordinal);
}
[Fact]
public async Task InternalEndpointAcceptsExactSecretButFailsClosedWithoutProvider()
{
using var client = _factory.CreateClient();
client.DefaultRequestHeaders.Add("X-D3RO-STT-Gateway-Token", GatewayToken);
using var body = AudioBody();
var response = await client.PostAsync("/api/stt/internal/transcribe", body);
Assert.Equal(HttpStatusCode.ServiceUnavailable, response.StatusCode);
Assert.Contains("stt_provider_unavailable", await response.Content.ReadAsStringAsync(), StringComparison.Ordinal);
}
private static StringContent JsonBody() =>
new("{\"audioBase64\":\"AQID\"}", Encoding.UTF8, "application/json");
private static MultipartFormDataContent AudioBody()
{
var body = new MultipartFormDataContent();
var audio = new ByteArrayContent([1, 2, 3, 4]);
audio.Headers.ContentType = new MediaTypeHeaderValue("audio/wav");
body.Add(audio, "file", "fixture.wav");
return body;
}
private static string UserToken()
{
var credentials = new SigningCredentials(
new SymmetricSecurityKey(Encoding.UTF8.GetBytes(Secret)),
SecurityAlgorithms.HmacSha256);
var token = new JwtSecurityToken(
issuer: Issuer,
audience: Audience,
claims:
[
new Claim(ClaimTypes.NameIdentifier, "1"),
new Claim(ClaimTypes.Email, "user@example.com"),
new Claim(ClaimTypes.Role, "user")
],
expires: DateTime.UtcNow.AddMinutes(5),
signingCredentials: credentials);
return new JwtSecurityTokenHandler().WriteToken(token);
}
}