Finalize FrontOffice accept/reject: role-aware, authz, signature; drop Signature.FilePath
FrontOfficeApiController.AcceptQuery/RejectQuery now resolve the actor (provider vs client vs admin) from the query's PerformerId/ClientId and authorize accordingly — a non-party caller is forbidden. Either party may accept or reject. Acceptance may carry a PostIt signature, which is staged on the query's linked estimate (Estimate.CommandId == queryId) and sets the estimate's ProviderValidationDate / ClientValidationDate; a signature with no linked estimate, or a signature on a reject, returns 400. Admin bypasses authz but cannot sign. The shared signature-staging logic (find-or-add the Signature row by (EstimateId, Type), write Strokes/CoordinateMax/CapturedAtUtc) is extracted into EstimateSignaturePersister, and BillingController.Sign now calls it. The persister owns no disk I/O and no SaveChanges — the caller persists in its own transaction. Signature.Strokes is now the sole source of truth: drop the FilePath property, remove the on-disk EstimateSignatureFileHelper (and its tests), and add the DropSignatureFilePath migration. The legacy PNG prosign/clisign flow is independent of Signature.FilePath and is unchanged. Co-Authored-By: Claude Code <noreply@anthropic.com>
This commit is contained in:
parent
9aef10f281
commit
709846c58d
10 changed files with 5249 additions and 430 deletions
|
|
@ -1,9 +1,12 @@
|
|||
using System.Net;
|
||||
using System.Net.Http.Headers;
|
||||
using System.Net.Http.Json;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Yavsc.Api.Test.Fixtures;
|
||||
using Yavsc.Helpers;
|
||||
using Yavsc.Models;
|
||||
using Yavsc.Models.Billing;
|
||||
using Yavsc.Models.Workflow;
|
||||
using Yavsc.Tests.Shared;
|
||||
|
||||
namespace Yavsc.Api.Test;
|
||||
|
|
@ -34,21 +37,88 @@ public sealed class FrontOfficeApiControllerTests : IClassFixture<ApiWebServerFi
|
|||
return http;
|
||||
}
|
||||
|
||||
// Minimal valid PostIt wire-format signature: one stroke of one
|
||||
// point (k=1, then 2*k coords). See PostIt.Models.SignaturePadData.
|
||||
private static readonly int[] SampleStrokes = { 1, 5000, 5000 };
|
||||
|
||||
private static HttpContent NoSignature() => JsonBody(new { });
|
||||
|
||||
private static HttpContent WithSignature() =>
|
||||
JsonBody(new { strokes = SampleStrokes, coordinateMax = 10_000 });
|
||||
|
||||
private static JsonContent JsonBody(object value) =>
|
||||
JsonContent.Create(value, mediaType: new MediaTypeHeaderValue("application/json"));
|
||||
|
||||
/// <summary>
|
||||
/// Seeds an Rdv query with distinct parties (provider alice,
|
||||
/// client bob) and, when <paramref name="withEstimate"/> is true,
|
||||
/// an estimate linked to it via CommandId. Returns the query id
|
||||
/// and (when seeded) the estimate id.
|
||||
/// </summary>
|
||||
private (long queryId, long estimateId) SeedQuery(bool withEstimate)
|
||||
{
|
||||
WorkflowHelpers.ConfigureBillingService();
|
||||
_fixture.ResetAndSeedActivityGraph();
|
||||
|
||||
using var scope = _fixture.Services.CreateScope();
|
||||
var db = scope.ServiceProvider.GetRequiredService<ApplicationDbContext>();
|
||||
|
||||
var location = db.Locations.Single(l => l.Address == "1 rue du Test");
|
||||
|
||||
var query = new RdvQuery
|
||||
{
|
||||
ActivityCode = "dev",
|
||||
ClientId = "bob",
|
||||
PerformerId = "alice",
|
||||
Consent = true,
|
||||
UserCreated = "alice",
|
||||
UserModified = "alice",
|
||||
DateCreated = DateTime.UtcNow,
|
||||
DateModified = DateTime.UtcNow,
|
||||
EventDate = DateTime.UtcNow.AddDays(1),
|
||||
Location = location,
|
||||
Reason = "Initial rendez-vous",
|
||||
Status = Yavsc.QueryStatus.Inserted,
|
||||
};
|
||||
db.RdvQueries.Add(query);
|
||||
db.SaveChanges();
|
||||
|
||||
long estimateId = 0;
|
||||
if (withEstimate)
|
||||
{
|
||||
var estimate = new Estimate
|
||||
{
|
||||
CommandId = query.Id,
|
||||
ClientId = "bob",
|
||||
OwnerId = "alice",
|
||||
CommandType = BillingCodes.Rdv,
|
||||
Title = "Devis prestation",
|
||||
Description = "Devis de test",
|
||||
AttachedFiles = new List<string>(),
|
||||
AttachedGraphics = new List<string>(),
|
||||
Bill = new List<CommandLine>
|
||||
{
|
||||
new() { Name = "Prestation", Description = "Prestation de base", Count = 1, UnitaryCost = 120m, Currency = "EUR" },
|
||||
},
|
||||
};
|
||||
db.Estimates.Add(estimate);
|
||||
db.SaveChanges();
|
||||
estimateId = estimate.Id;
|
||||
}
|
||||
|
||||
return (query.Id, estimateId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Front_accept_query_updates_status_without_server_error()
|
||||
{
|
||||
WorkflowHelpers.ConfigureBillingService();
|
||||
_fixture.ResetAndSeedRdvQueryGraph();
|
||||
|
||||
long queryId;
|
||||
using (var scope = _fixture.Services.CreateScope())
|
||||
{
|
||||
var db = scope.ServiceProvider.GetRequiredService<ApplicationDbContext>();
|
||||
queryId = db.RdvQueries.Select(q => q.Id).Single();
|
||||
}
|
||||
var (queryId, _) = SeedQuery(withEstimate: false);
|
||||
|
||||
using var http = NewClient();
|
||||
var response = await http.PostAsync($"/api/v1/front/query/accept?billingCode=Rdv&queryId={queryId}", content: null, TestContext.Current.CancellationToken);
|
||||
var response = await http.PostAsync(
|
||||
$"/api/v1/front/query/accept?billingCode=Rdv&queryId={queryId}",
|
||||
NoSignature(),
|
||||
TestContext.Current.CancellationToken);
|
||||
var body = await response.Content.ReadAsStringAsync(TestContext.Current.CancellationToken);
|
||||
|
||||
Assert.True(response.StatusCode == HttpStatusCode.OK, $"Unexpected status {(int)response.StatusCode} ({response.StatusCode}): {body}");
|
||||
|
|
@ -59,4 +129,118 @@ public sealed class FrontOfficeApiControllerTests : IClassFixture<ApiWebServerFi
|
|||
|
||||
Assert.Equal(QueryStatus.Accepted, updated.Status);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Provider_accept_with_signature_sets_ProValidationDate_and_Pro_signature()
|
||||
{
|
||||
var (queryId, estimateId) = SeedQuery(withEstimate: true);
|
||||
|
||||
using var http = NewClient(subject: "alice");
|
||||
var response = await http.PostAsync(
|
||||
$"/api/v1/front/query/accept?billingCode=Rdv&queryId={queryId}",
|
||||
WithSignature(),
|
||||
TestContext.Current.CancellationToken);
|
||||
var body = await response.Content.ReadAsStringAsync(TestContext.Current.CancellationToken);
|
||||
Assert.True(response.StatusCode == HttpStatusCode.OK, $"Unexpected status {(int)response.StatusCode} ({response.StatusCode}): {body}");
|
||||
|
||||
using var assertScope = _fixture.Services.CreateScope();
|
||||
var assertDb = assertScope.ServiceProvider.GetRequiredService<ApplicationDbContext>();
|
||||
|
||||
Assert.Equal(QueryStatus.Accepted, assertDb.RdvQueries.Single(q => q.Id == queryId).Status);
|
||||
|
||||
var estimate = assertDb.Estimates.Single(e => e.Id == estimateId);
|
||||
Assert.True(estimate.ProviderValidationDate != default);
|
||||
Assert.True(estimate.ClientValidationDate == default);
|
||||
|
||||
var signature = assertDb.Signatures.Single(s => s.EstimateId == estimateId);
|
||||
Assert.Equal(SignatureType.Pro, signature.Type);
|
||||
Assert.Equal("alice", signature.SignerId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Client_accept_with_signature_sets_ClientValidationDate_and_Client_signature()
|
||||
{
|
||||
var (queryId, estimateId) = SeedQuery(withEstimate: true);
|
||||
|
||||
using var http = NewClient(subject: "bob");
|
||||
var response = await http.PostAsync(
|
||||
$"/api/v1/front/query/accept?billingCode=Rdv&queryId={queryId}",
|
||||
WithSignature(),
|
||||
TestContext.Current.CancellationToken);
|
||||
var body = await response.Content.ReadAsStringAsync(TestContext.Current.CancellationToken);
|
||||
Assert.True(response.StatusCode == HttpStatusCode.OK, $"Unexpected status {(int)response.StatusCode} ({response.StatusCode}): {body}");
|
||||
|
||||
using var assertScope = _fixture.Services.CreateScope();
|
||||
var assertDb = assertScope.ServiceProvider.GetRequiredService<ApplicationDbContext>();
|
||||
|
||||
Assert.Equal(QueryStatus.Accepted, assertDb.RdvQueries.Single(q => q.Id == queryId).Status);
|
||||
|
||||
var estimate = assertDb.Estimates.Single(e => e.Id == estimateId);
|
||||
Assert.True(estimate.ClientValidationDate != default);
|
||||
Assert.True(estimate.ProviderValidationDate == default);
|
||||
|
||||
var signature = assertDb.Signatures.Single(s => s.EstimateId == estimateId);
|
||||
Assert.Equal(SignatureType.Client, signature.Type);
|
||||
Assert.Equal("bob", signature.SignerId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Reject_sets_status_Rejected()
|
||||
{
|
||||
var (queryId, _) = SeedQuery(withEstimate: false);
|
||||
|
||||
using var http = NewClient(subject: "alice");
|
||||
var response = await http.PostAsync(
|
||||
$"/api/v1/front/query/reject?billingCode=Rdv&queryId={queryId}",
|
||||
NoSignature(),
|
||||
TestContext.Current.CancellationToken);
|
||||
var body = await response.Content.ReadAsStringAsync(TestContext.Current.CancellationToken);
|
||||
Assert.True(response.StatusCode == HttpStatusCode.OK, $"Unexpected status {(int)response.StatusCode} ({response.StatusCode}): {body}");
|
||||
|
||||
using var assertScope = _fixture.Services.CreateScope();
|
||||
var assertDb = assertScope.ServiceProvider.GetRequiredService<ApplicationDbContext>();
|
||||
Assert.Equal(QueryStatus.Rejected, assertDb.RdvQueries.Single(q => q.Id == queryId).Status);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Non_party_actor_is_forbidden()
|
||||
{
|
||||
var (queryId, _) = SeedQuery(withEstimate: false);
|
||||
|
||||
using var http = NewClient(subject: "carol");
|
||||
var response = await http.PostAsync(
|
||||
$"/api/v1/front/query/accept?billingCode=Rdv&queryId={queryId}",
|
||||
NoSignature(),
|
||||
TestContext.Current.CancellationToken);
|
||||
|
||||
Assert.Equal(HttpStatusCode.Forbidden, response.StatusCode);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Accept_with_signature_but_no_estimate_returns_400()
|
||||
{
|
||||
var (queryId, _) = SeedQuery(withEstimate: false);
|
||||
|
||||
using var http = NewClient(subject: "alice");
|
||||
var response = await http.PostAsync(
|
||||
$"/api/v1/front/query/accept?billingCode=Rdv&queryId={queryId}",
|
||||
WithSignature(),
|
||||
TestContext.Current.CancellationToken);
|
||||
|
||||
Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Reject_with_signature_returns_400()
|
||||
{
|
||||
var (queryId, _) = SeedQuery(withEstimate: true);
|
||||
|
||||
using var http = NewClient(subject: "alice");
|
||||
var response = await http.PostAsync(
|
||||
$"/api/v1/front/query/reject?billingCode=Rdv&queryId={queryId}",
|
||||
WithSignature(),
|
||||
TestContext.Current.CancellationToken);
|
||||
|
||||
Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
|
||||
}
|
||||
}
|
||||
Loading…
Reference in a new issue