feat(release): prepare 1.1.0 candidate
This commit is contained in:
parent
5a34f66981
commit
5205dcdfa9
736 changed files with 115667 additions and 12203 deletions
119
apps/api-server.Tests/AdminAuthorizationE2ETests.cs
Normal file
119
apps/api-server.Tests/AdminAuthorizationE2ETests.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
97
apps/api-server.Tests/AdminOperationServiceTests.cs
Normal file
97
apps/api-server.Tests/AdminOperationServiceTests.cs
Normal 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());
|
||||
}
|
||||
}
|
||||
91
apps/api-server.Tests/AuthBootstrapControllerTests.cs
Normal file
91
apps/api-server.Tests/AuthBootstrapControllerTests.cs
Normal 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();
|
||||
}
|
||||
}
|
||||
118
apps/api-server.Tests/AuthSecurityTests.cs
Normal file
118
apps/api-server.Tests/AuthSecurityTests.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
25
apps/api-server.Tests/D3ROVoice.Api.Tests.csproj
Normal file
25
apps/api-server.Tests/D3ROVoice.Api.Tests.csproj
Normal 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>
|
||||
191
apps/api-server.Tests/SttControllerSecurityTests.cs
Normal file
191
apps/api-server.Tests/SttControllerSecurityTests.cs
Normal 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();
|
||||
}
|
||||
}
|
||||
223
apps/api-server.Tests/SttFailClosedTests.cs
Normal file
223
apps/api-server.Tests/SttFailClosedTests.cs
Normal 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));
|
||||
}
|
||||
}
|
||||
}
|
||||
100
apps/api-server.Tests/SttGatewayAuthorizationE2ETests.cs
Normal file
100
apps/api-server.Tests/SttGatewayAuthorizationE2ETests.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue