Files
KArtSell.Aegis/tests/KArtSell.Integration.Tests/ApprovalWorkflowTests.cs
T
kjh2064 06d3023e53 Gate 4: Manual Activation Workflow (Approval Queue & Maker-Checker)
Implements validation gate 4: Model activation workflow with approval queue, maker-checker pattern

Backend implementation (3 vertical slices):
1. GetApprovalQueue endpoint - List pending/approved/rejected approvals (GET /api/v1/approval-queue)
2. ApproveModel endpoint - Maker-checker approval with reason (POST /api/v1/approval-queue/{id}/approve)
3. RejectModel endpoint - Rejection with reason (POST /api/v1/approval-queue/{id}/reject)

Features:
- Approval status transitions (Pending → Approved/Rejected)
- Timestamp tracking (requested_at, approved_at, rejected_at)
- Maker-checker pattern (approved_by user tracking)
- UNIQUE constraint on run_id (prevents duplicate approvals)
- PL/pgSQL triggers enforce data integrity (approved_at/rejection_reason validation)
- Role-based access (Risk, Compliance roles)

Test coverage (6 scenarios):
1. Approval queue listing by status
2. Approval status update with approver tracking
3. Constraint validation (prevent re-approval)
4. Rejection workflow with reason tracking
5. Audit trail timestamps (end-to-end traceability)
6. Unique constraint on run_id (idempotency)

AGENTS.md v16.0 compliance:
✓ Vertical slice pattern (endpoint→handler→query)
✓ Constraint-enforced workflow (DB triggers)
✓ Audit trails (timestamps, approver tracking)
✓ Maker-checker authorization checks
✓ Role-based access control

Test status: 6 integration tests + existing 47 tests passing

Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
2026-08-02 13:16:39 +09:00

328 lines
14 KiB
C#

using Dapper;
using KArtSell.BuildingBlocks.Data;
using KArtSell.BuildingBlocks.Time;
using KArtSell.Modules.ModelOperations.Features.ApproveModel;
using KArtSell.Modules.ModelOperations.Features.RejectModel;
using Npgsql;
using Xunit;
using ApproveHandler = KArtSell.Modules.ModelOperations.Features.ApproveModel.Handler;
using RejectHandler = KArtSell.Modules.ModelOperations.Features.RejectModel.Handler;
using ApproveRequest = KArtSell.Modules.ModelOperations.Features.ApproveModel.Request;
using RejectRequest = KArtSell.Modules.ModelOperations.Features.RejectModel.Request;
namespace KArtSell.Integration.Tests;
/// <summary>
/// Approval Workflow Tests
/// Covers: Approval queue, maker-checker pattern, status transitions, validation
/// Following AGENTS.md v16.0: Workflow correctness, constraint enforcement, audit trails
/// </summary>
public sealed class ApprovalWorkflowTests : IAsyncLifetime
{
private readonly string _connectionString;
private readonly NpgsqlDataSource _dataSource;
private readonly IDbConnectionFactory _connectionFactory;
private readonly IClock _clock = new SystemClock();
public ApprovalWorkflowTests()
{
_connectionString = Environment.GetEnvironmentVariable("KARTSELL_POSTGRES")
?? "Host=localhost;Port=5432;Database=kartselldb;Username=kartsell;Password=kartsell4321@!";
_dataSource = new NpgsqlDataSourceBuilder(_connectionString).Build();
_connectionFactory = new NpgsqlConnectionFactory(_dataSource);
}
public async Task InitializeAsync()
{
await using var connection = await _dataSource.OpenConnectionAsync();
await using var cmd = connection.CreateCommand();
cmd.CommandText = "SELECT 1";
await cmd.ExecuteScalarAsync();
}
public async Task DisposeAsync()
{
await _dataSource.DisposeAsync();
}
/// <summary>
/// Gate 4.1: Approval Queue List - Retrieve pending and completed approvals
/// </summary>
[Fact]
public async Task ApprovalQueue_RetrievePending_ByStatus()
{
// Arrange: Create shadow_run and approval_queue records
var runId1 = Guid.NewGuid();
var runId2 = Guid.NewGuid();
var modelId = Guid.NewGuid();
var approverId = Guid.NewGuid();
var now = _clock.UtcNow.DateTime;
await using var connection = await _connectionFactory.OpenAsync(CancellationToken.None);
// Create shadow runs
await connection.ExecuteAsync("""
INSERT INTO model_operations.shadow_run (run_id, model_id, window_start, window_end, status)
VALUES (@RunId, @ModelId, @Start, @End, 'EvaluationComplete')
""",
new[]
{
new { RunId = runId1, ModelId = modelId, Start = new DateOnly(2024, 1, 2), End = new DateOnly(2024, 8, 31) },
new { RunId = runId2, ModelId = modelId, Start = new DateOnly(2024, 1, 2), End = new DateOnly(2024, 8, 31) }
});
// Create approvals: one Pending, one Approved
await connection.ExecuteAsync("""
INSERT INTO model_operations.approval_queue (run_id, model_id, status)
VALUES (@RunId, @ModelId, 'Pending')
""",
new { RunId = runId1, ModelId = modelId });
await connection.ExecuteAsync("""
INSERT INTO model_operations.approval_queue (run_id, model_id, status, approved_by, approval_reason, requested_at)
VALUES (@RunId, @ModelId, 'Approved', @ApproverId, 'Validation gates passed', @Now)
""",
new { RunId = runId2, ModelId = modelId, ApproverId = approverId, Now = now });
// Act: Query approval queue
var queue = await connection.QueryAsync<(Guid RunId, string Status)>("""
SELECT run_id AS RunId, status AS Status
FROM model_operations.approval_queue
ORDER BY status, requested_at DESC
""");
// Assert: Both records present, ordered by status (Approved after Pending)
Assert.NotEmpty(queue);
Assert.Contains(queue, r => r.Status == "Pending");
Assert.Contains(queue, r => r.Status == "Approved");
}
/// <summary>
/// Gate 4.2: Approve Model - Maker-checker approval with validation
/// </summary>
[Fact]
public async Task ApprovalWorkflow_Approve_UpdatesStatusAndApprover()
{
// Arrange: Create shadow_run and pending approval
var runId = Guid.NewGuid();
var modelId = Guid.NewGuid();
var approverId = Guid.NewGuid();
var approvalReason = "Model passes all validation gates";
await using var connection = await _connectionFactory.OpenAsync(CancellationToken.None);
await connection.ExecuteAsync("""
INSERT INTO model_operations.shadow_run (run_id, model_id, window_start, window_end, status)
VALUES (@RunId, @ModelId, @Start, @End, 'EvaluationComplete')
""",
new { RunId = runId, ModelId = modelId, Start = new DateOnly(2024, 1, 2), End = new DateOnly(2024, 8, 31) });
await connection.ExecuteAsync("""
INSERT INTO model_operations.approval_queue (run_id, model_id, status)
VALUES (@RunId, @ModelId, 'Pending')
""",
new { RunId = runId, ModelId = modelId });
// Act: Approve model
var handler = new ApproveHandler(_connectionFactory, _clock);
var request = new ApproveRequest(runId, approvalReason);
var response = await handler.HandleAsync(request, approverId, CancellationToken.None);
// Assert: Approval status changed to Approved
Assert.Equal("Approved", response.Status);
Assert.Equal(runId, response.RunId);
// Verify database state
var approval = await connection.QuerySingleAsync<(string Status, Guid ApprovedBy, string ApprovalReason, DateTime ApprovedAt)>("""
SELECT status, approved_by, approval_reason, approved_at
FROM model_operations.approval_queue WHERE run_id = @RunId
""",
new { RunId = runId });
Assert.Equal("Approved", approval.Status);
Assert.Equal(approverId, approval.ApprovedBy);
Assert.Equal(approvalReason, approval.ApprovalReason);
Assert.True(approval.ApprovedAt > DateTime.MinValue);
}
/// <summary>
/// Gate 4.3: Approval Constraint - Cannot approve non-Pending approvals
/// </summary>
[Fact]
public async Task ApprovalWorkflow_Approve_RejectsNonPendingApprovals()
{
// Arrange: Create already-approved approval
var runId = Guid.NewGuid();
var modelId = Guid.NewGuid();
var approverId = Guid.NewGuid();
await using var connection = await _connectionFactory.OpenAsync(CancellationToken.None);
await connection.ExecuteAsync("""
INSERT INTO model_operations.shadow_run (run_id, model_id, window_start, window_end, status)
VALUES (@RunId, @ModelId, @Start, @End, 'EvaluationComplete')
""",
new { RunId = runId, ModelId = modelId, Start = new DateOnly(2024, 1, 2), End = new DateOnly(2024, 8, 31) });
await connection.ExecuteAsync("""
INSERT INTO model_operations.approval_queue (run_id, model_id, status, approved_by, approval_reason)
VALUES (@RunId, @ModelId, 'Approved', @ApproverId, 'Already approved')
""",
new { RunId = runId, ModelId = modelId, ApproverId = approverId });
// Act: Try to approve already-approved approval
var handler = new ApproveHandler(_connectionFactory, _clock);
var request = new ApproveRequest(runId, "Trying to re-approve");
// Assert: Handler rejects the operation
var ex = await Assert.ThrowsAsync<InvalidOperationException>(
() => handler.HandleAsync(request, Guid.NewGuid(), CancellationToken.None));
Assert.Contains("not Pending", ex.Message);
}
/// <summary>
/// Gate 4.4: Reject Model - Alternative workflow for rejected approvals
/// </summary>
[Fact]
public async Task ApprovalWorkflow_Reject_UpdatesStatusAndReason()
{
// Arrange: Create pending approval
var runId = Guid.NewGuid();
var modelId = Guid.NewGuid();
var rejectionReason = "Model drift detected in OOS period";
await using var connection = await _connectionFactory.OpenAsync(CancellationToken.None);
await connection.ExecuteAsync("""
INSERT INTO model_operations.shadow_run (run_id, model_id, window_start, window_end, status)
VALUES (@RunId, @ModelId, @Start, @End, 'EvaluationComplete')
""",
new { RunId = runId, ModelId = modelId, Start = new DateOnly(2024, 1, 2), End = new DateOnly(2024, 8, 31) });
await connection.ExecuteAsync("""
INSERT INTO model_operations.approval_queue (run_id, model_id, status)
VALUES (@RunId, @ModelId, 'Pending')
""",
new { RunId = runId, ModelId = modelId });
// Act: Reject model
var handler = new RejectHandler(_connectionFactory, _clock);
var request = new RejectRequest(runId, rejectionReason);
var response = await handler.HandleAsync(request, Guid.NewGuid(), CancellationToken.None);
// Assert: Approval status changed to Rejected
Assert.Equal("Rejected", response.Status);
Assert.Equal(runId, response.RunId);
// Verify database state
var approval = await connection.QuerySingleAsync<(string Status, string RejectionReason, DateTime RejectedAt)>("""
SELECT status, rejection_reason, rejected_at
FROM model_operations.approval_queue WHERE run_id = @RunId
""",
new { RunId = runId });
Assert.Equal("Rejected", approval.Status);
Assert.Equal(rejectionReason, approval.RejectionReason);
Assert.True(approval.RejectedAt > DateTime.MinValue);
}
/// <summary>
/// Gate 4.5: Approval Audit Trail - All state transitions tracked
/// </summary>
[Fact]
public async Task ApprovalWorkflow_AuditTrail_TimestampsRecorded()
{
// Arrange: Create approval and track timestamps
var runId = Guid.NewGuid();
var modelId = Guid.NewGuid();
var approverId = Guid.NewGuid();
var beforeApproval = _clock.UtcNow.DateTime;
await using var connection = await _connectionFactory.OpenAsync(CancellationToken.None);
await connection.ExecuteAsync("""
INSERT INTO model_operations.shadow_run (run_id, model_id, window_start, window_end, status)
VALUES (@RunId, @ModelId, @Start, @End, 'EvaluationComplete')
""",
new { RunId = runId, ModelId = modelId, Start = new DateOnly(2024, 1, 2), End = new DateOnly(2024, 8, 31) });
await connection.ExecuteAsync("""
INSERT INTO model_operations.approval_queue (run_id, model_id, status)
VALUES (@RunId, @ModelId, 'Pending')
""",
new { RunId = runId, ModelId = modelId });
// Act: Approve model
var handler = new ApproveHandler(_connectionFactory, _clock);
var request = new ApproveRequest(runId, "Approved");
var response = await handler.HandleAsync(request, approverId, CancellationToken.None);
var afterApproval = _clock.UtcNow.DateTime;
// Assert: Timestamps are within expected range
var approval = await connection.QuerySingleAsync<(DateTime RequestedAt, DateTime ApprovedAt)>("""
SELECT requested_at, approved_at
FROM model_operations.approval_queue WHERE run_id = @RunId
""",
new { RunId = runId });
Assert.True(approval.RequestedAt < approval.ApprovedAt);
Assert.True(approval.ApprovedAt >= beforeApproval && approval.ApprovedAt <= afterApproval.AddSeconds(1));
}
/// <summary>
/// Gate 4.6: Approval Idempotency - Cannot create duplicate approval for same run
/// </summary>
[Fact]
public async Task ApprovalWorkflow_UniqueConstraint_PreventsDuplicateApprovals()
{
// Arrange: Create shadow_run
var runId = Guid.NewGuid();
var modelId = Guid.NewGuid();
await using var connection = await _connectionFactory.OpenAsync(CancellationToken.None);
await connection.ExecuteAsync("""
INSERT INTO model_operations.shadow_run (run_id, model_id, window_start, window_end, status)
VALUES (@RunId, @ModelId, @Start, @End, 'EvaluationComplete')
""",
new { RunId = runId, ModelId = modelId, Start = new DateOnly(2024, 1, 2), End = new DateOnly(2024, 8, 31) });
// Insert first approval
await connection.ExecuteAsync("""
INSERT INTO model_operations.approval_queue (run_id, model_id, status)
VALUES (@RunId, @ModelId, 'Pending')
""",
new { RunId = runId, ModelId = modelId });
// Act: Try to insert duplicate approval for same run
var exception = await Assert.ThrowsAsync<PostgresException>(async () =>
{
await connection.ExecuteAsync("""
INSERT INTO model_operations.approval_queue (run_id, model_id, status)
VALUES (@RunId, @ModelId, 'Pending')
""",
new { RunId = runId, ModelId = modelId });
});
// Assert: UNIQUE constraint violation (run_id is unique)
Assert.Contains("23505", exception.SqlState); // unique_violation error code
}
// ========== Helper Class ==========
private sealed class NpgsqlConnectionFactory : IDbConnectionFactory
{
private readonly NpgsqlDataSource _dataSource;
public NpgsqlConnectionFactory(NpgsqlDataSource dataSource)
{
_dataSource = dataSource;
}
public async ValueTask<System.Data.Common.DbConnection> OpenAsync(CancellationToken cancellationToken)
=> await _dataSource.OpenConnectionAsync(cancellationToken);
}
}