feat: add audited one-shot prepare gate

This commit is contained in:
2026-07-18 12:29:09 +09:00
parent 1302b1b92f
commit 5379511b8a
25 changed files with 6188 additions and 41 deletions

View File

@@ -0,0 +1,15 @@
#nullable enable
namespace MBN_STOCK_WEBVIEW.Infrastructure.Tests;
internal sealed class DenyAllDatabaseMutationAuthorization
: IDatabaseMutationAuthorization
{
internal List<DatabaseMutationAuthorizationRequest> Requests { get; } = [];
public bool IsAuthorized(DatabaseMutationAuthorizationRequest request)
{
Requests.Add(request);
return false;
}
}

View File

@@ -290,8 +290,37 @@ public sealed class OperatorCatalogMutationExecutorTests
Assert.Equal(0, plan.BeginCount);
}
[Fact]
public async Task ExecuteTransactionAsync_DeniedAuthorizationCreatesNoConnectionOrTransaction()
{
var plan = new CatalogMutationConnectionPlan();
var factory = new CatalogMutationConnectionFactory(plan);
var authorization = new DenyAllDatabaseMutationAuthorization();
var service = new LegacyExpertCatalogPersistenceService(
new CatalogNoQueryExecutor(),
CreateExecutor(factory, authorization));
var exception = await Assert.ThrowsAsync<OperatorCatalogMutationException>(() =>
service.CreateAsync(new ExpertCatalogDefinition(
new ExpertSelectionIdentity("0001", "Expert"),
[])));
Assert.False(exception.OutcomeUnknown);
var denied = Assert.IsType<DatabaseMutationAuthorizationException>(
exception.InnerException);
Assert.Equal(DatabaseMutationTarget.OperatorCatalog, denied.Request.Target);
Assert.Equal(DataSourceKind.Oracle, denied.Request.Source);
Assert.Equal(nameof(OperatorCatalogMutationKind.CreateExpert), denied.Request.MutationKind);
Assert.NotEqual(Guid.Empty, denied.Request.OperationId);
Assert.Single(authorization.Requests);
Assert.Equal(0, factory.CreateCount);
Assert.Equal(0, plan.BeginCount);
Assert.Empty(plan.Commands);
}
private static OperatorCatalogMutationExecutor CreateExecutor(
IDatabaseConnectionFactory factory) =>
IDatabaseConnectionFactory factory,
IDatabaseMutationAuthorization? mutationAuthorization = null) =>
new(
factory,
new DatabaseResilienceOptions
@@ -301,7 +330,8 @@ public sealed class OperatorCatalogMutationExecutorTests
InitialRetryDelayMilliseconds = 1
},
new StubErrorDetector(transient: true),
static (_, _) => { });
static (_, _) => { },
mutationAuthorization ?? DatabaseMutationAuthorization.AllowAll);
private static ObservedCatalogMutationParameter Parameter(
ObservedCatalogMutationCommand command,

View File

@@ -68,6 +68,31 @@ public sealed class OracleManualFinancialMutationExecutorTests
Assert.True(plan.TransactionDisposed);
}
[Fact]
public async Task DeniedAuthorizationCreatesNoConnectionOrTransaction()
{
var plan = new Plan();
var factory = new Factory(plan);
var authorization = new DenyAllDatabaseMutationAuthorization();
var service = Service(Executor(factory, mutationAuthorization: authorization));
var exception = await Assert.ThrowsAsync<ManualFinancialMutationException>(() =>
service.CreateAsync(Sales("Alpha")));
Assert.False(exception.OutcomeUnknown);
var denied = Assert.IsType<DatabaseMutationAuthorizationException>(
exception.InnerException);
Assert.Equal(DatabaseMutationTarget.ManualFinancial, denied.Request.Target);
Assert.Equal(DataSourceKind.Oracle, denied.Request.Source);
Assert.Equal(nameof(ManualFinancialMutationKind.Create), denied.Request.MutationKind);
Assert.NotEqual(Guid.Empty, denied.Request.OperationId);
Assert.Single(authorization.Requests);
Assert.Equal(0, factory.CreateCount);
Assert.Equal(0, plan.OpenCount);
Assert.Equal(0, plan.BeginCount);
Assert.Empty(plan.Commands);
}
[Fact]
public async Task Zero_affected_create_rolls_back_as_known_duplicate_before_commit()
{
@@ -141,7 +166,8 @@ public sealed class OracleManualFinancialMutationExecutorTests
private static OracleManualFinancialMutationExecutor Executor(
Factory factory,
int operationTimeoutSeconds = 30) =>
int operationTimeoutSeconds = 30,
IDatabaseMutationAuthorization? mutationAuthorization = null) =>
new(
factory,
new DatabaseResilienceOptions
@@ -151,7 +177,8 @@ public sealed class OracleManualFinancialMutationExecutorTests
InitialRetryDelayMilliseconds = 30_000
},
new StubErrorDetector(false),
command => Assert.IsType<FakeCommand>(command).BindByName = true);
command => Assert.IsType<FakeCommand>(command).BindByName = true,
mutationAuthorization ?? DatabaseMutationAuthorization.AllowAll);
private static ManualSalesRecord Sales(string stockName) =>
new(

View File

@@ -283,6 +283,31 @@ public sealed class OracleNamedPlaylistMutationExecutorTests
Assert.Empty(plan.Commands);
}
[Fact]
public async Task DeniedAuthorizationCreatesNoConnectionOrTransaction()
{
var plan = new MutationPlan();
var factory = new MutationConnectionFactory(plan);
var authorization = new DenyAllDatabaseMutationAuthorization();
var service = CreateService(CreateExecutor(factory, mutationAuthorization: authorization));
var exception = await Assert.ThrowsAsync<NamedPlaylistMutationException>(
() => service.CreateDefinitionAsync("00000011", "Morning"));
Assert.False(exception.OutcomeUnknown);
var denied = Assert.IsType<DatabaseMutationAuthorizationException>(
exception.InnerException);
Assert.Equal(DatabaseMutationTarget.NamedPlaylist, denied.Request.Target);
Assert.Equal(DataSourceKind.Oracle, denied.Request.Source);
Assert.Equal(nameof(NamedPlaylistMutationKind.CreateDefinition), denied.Request.MutationKind);
Assert.NotEqual(Guid.Empty, denied.Request.OperationId);
Assert.Single(authorization.Requests);
Assert.Equal(0, factory.CreateCount);
Assert.Equal(0, plan.OpenCount);
Assert.Equal(0, plan.BeginCount);
Assert.Empty(plan.Commands);
}
[Fact]
public void Constructor_RejectsInvalidCommandTimeout()
{
@@ -296,7 +321,8 @@ public sealed class OracleNamedPlaylistMutationExecutorTests
private static OracleNamedPlaylistMutationExecutor CreateExecutor(
MutationConnectionFactory factory,
int operationTimeoutSeconds = 30) =>
int operationTimeoutSeconds = 30,
IDatabaseMutationAuthorization? mutationAuthorization = null) =>
new(
factory,
new DatabaseResilienceOptions
@@ -306,7 +332,8 @@ public sealed class OracleNamedPlaylistMutationExecutorTests
InitialRetryDelayMilliseconds = 30_000
},
new TransientDatabaseErrorDetector(),
command => Assert.IsType<MutationFakeCommand>(command).BindByName = true);
command => Assert.IsType<MutationFakeCommand>(command).BindByName = true,
mutationAuthorization ?? DatabaseMutationAuthorization.AllowAll);
private static LegacyNamedPlaylistPersistenceService CreateService(
INamedPlaylistMutationExecutor mutations) =>