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:
Paul Schneider 2026-09-21 16:15:18 +01:00
commit eb560045b9
10 changed files with 5249 additions and 430 deletions

View file

@ -1,9 +1,12 @@
using System.Net; using System.Net;
using System.Net.Http.Headers; using System.Net.Http.Headers;
using System.Net.Http.Json;
using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.DependencyInjection;
using Yavsc.Api.Test.Fixtures; using Yavsc.Api.Test.Fixtures;
using Yavsc.Helpers; using Yavsc.Helpers;
using Yavsc.Models; using Yavsc.Models;
using Yavsc.Models.Billing;
using Yavsc.Models.Workflow;
using Yavsc.Tests.Shared; using Yavsc.Tests.Shared;
namespace Yavsc.Api.Test; namespace Yavsc.Api.Test;
@ -34,21 +37,88 @@ public sealed class FrontOfficeApiControllerTests : IClassFixture<ApiWebServerFi
return http; 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] [Fact]
public async Task Front_accept_query_updates_status_without_server_error() public async Task Front_accept_query_updates_status_without_server_error()
{ {
WorkflowHelpers.ConfigureBillingService(); var (queryId, _) = SeedQuery(withEstimate: false);
_fixture.ResetAndSeedRdvQueryGraph();
long queryId;
using (var scope = _fixture.Services.CreateScope())
{
var db = scope.ServiceProvider.GetRequiredService<ApplicationDbContext>();
queryId = db.RdvQueries.Select(q => q.Id).Single();
}
using var http = NewClient(); 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); var body = await response.Content.ReadAsStringAsync(TestContext.Current.CancellationToken);
Assert.True(response.StatusCode == HttpStatusCode.OK, $"Unexpected status {(int)response.StatusCode} ({response.StatusCode}): {body}"); 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); 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);
}
} }

View file

@ -401,79 +401,12 @@ namespace Yavsc.ApiControllers
Strokes = body.Strokes, Strokes = body.Strokes,
}; };
// Disk write first: a disk failure shouldn't leave // The Signature row is staged by the shared persister; we
// a Signature row pointing at a file that doesn't // SaveChanges here so the signature write is one
// exist. The file helper throws on filesystem // transaction. The Strokes column is the source of truth —
// problems and propagates here. // there is no on-disk copy to roll back on failure.
FileReceivedInfo fi; var signature = await EstimateSignaturePersister.StageAsync(
try dbContext, id, type, userId, payload, token);
{
fi = await User.ReceiveEstimateSignatureAsync(id, type, payload,
siteSettings, token);
}
catch (Exception ex)
{
logger.LogError(ex, "estimate {Id}: signature file write failed", id);
return BadRequest(new { Error = "file write failed", Detail = ex.Message });
}
// Find-or-add: the (EstimateId, Type) pair is
// unique, so a second POST for the same side of the
// estimate replaces the previous signature. EF
// translates this into a single UPDATE when the
// row exists and an INSERT otherwise; the unique
// index in ApplicationDbContext is the
// database-level guarantee that the contract
// holds if two requests race.
var signature = await dbContext.Signatures
.FirstOrDefaultAsync(s => s.EstimateId == id && s.Type == type, token);
if (signature is null)
{
signature = new Signature
{
EstimateId = id,
SignerId = userId,
Type = type,
};
dbContext.Signatures.Add(signature);
}
else
{
// Roll the signer's quota back by the size of
// the file we're about to orphan: the old
// FilePath is no longer referenced once we
// overwrite FilePath below.
try
{
var orphan = new FileInfo(signature.FilePath);
if (orphan.Exists)
{
var signerForOrphan = await dbContext.Users
.FirstOrDefaultAsync(u => u.Id == userId, token);
if (signerForOrphan is not null)
signerForOrphan.DiskUsage =
Math.Max(0, signerForOrphan.DiskUsage - orphan.Length);
}
}
catch { /* best effort — the file is being replaced anyway */ }
}
signature.SignerId = userId;
signature.CoordinateMax = payload.CoordinateMax;
signature.Strokes = payload.Strokes;
signature.CapturedAtUtc = payload.CapturedAtUtc;
signature.FilePath = Path.Combine(fi.DestDir, fi.FileName);
// Bump the signer's quota. The Signature row's
// SignerId is the IdentityUser.Id (a string), so we
// look up by Id and not by username.
var signer = await dbContext.Users
.FirstOrDefaultAsync(u => u.Id == userId, token);
if (signer is not null)
{
signer.DiskUsage += new FileInfo(signature.FilePath).Length;
}
try try
{ {
@ -482,10 +415,6 @@ namespace Yavsc.ApiControllers
catch (Exception ex) catch (Exception ex)
{ {
logger.LogError(ex, "estimate {Id}: signature db write failed", id); logger.LogError(ex, "estimate {Id}: signature db write failed", id);
// Best-effort rollback: remove the file we wrote
// so disk and db don't disagree.
try { System.IO.File.Delete(signature.FilePath); }
catch { /* swallow — the row will be re-orphaned, the user re-signs */ }
return BadRequest(new { Error = "db write failed", Detail = ex.Message }); return BadRequest(new { Error = "db write failed", Detail = ex.Message });
} }

View file

@ -1,7 +1,10 @@
using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using Yavsc.Abstract.Workflow;
using Yavsc.Helpers; using Yavsc.Helpers;
using Yavsc.Models; using Yavsc.Models;
using Yavsc.Models.Billing;
using Yavsc.Services; using Yavsc.Services;
using Yavsc.Server.Helpers; using Yavsc.Server.Helpers;
using Yavsc.ViewModels.FrontOffice; using Yavsc.ViewModels.FrontOffice;
@ -13,13 +16,17 @@ namespace Yavsc.ApiControllers
public class FrontOfficeApiController : Controller public class FrontOfficeApiController : Controller
{ {
ApplicationDbContext dbContext; ApplicationDbContext dbContext;
private readonly IBillingService billing;
private readonly ILogger logger;
private IBillingService billing; public FrontOfficeApiController(
ApplicationDbContext context,
public FrontOfficeApiController(ApplicationDbContext context, IBillingService billing = null) ILoggerFactory loggerFactory,
IBillingService billing = null)
{ {
dbContext = context; dbContext = context;
this.billing = billing ?? new BillingService(context); this.billing = billing ?? new BillingService(context);
logger = loggerFactory.CreateLogger<FrontOfficeApiController>();
} }
[HttpGet("profiles/{actCode}")] [HttpGet("profiles/{actCode}")]
@ -28,29 +35,183 @@ namespace Yavsc.ApiControllers
return await dbContext.ListPerformersAsync(billing, actCode); return await dbContext.ListPerformersAsync(billing, actCode);
} }
[HttpPost("query/reject")] // POST: api/front/query/accept
public IActionResult RejectQuery(string billingCode, long queryId) //
{ // Either party of the query may accept: the provider
if (billingCode == null) return BadRequest("billingCode"); // (PerformerId) accepts the client's request, or the client
if (queryId == 0) return BadRequest("queryId"); // (ClientId) accepts the provider's estimate. An optional
var billing = BillingService.GetBillable(dbContext, billingCode, queryId); // signature (the PostIt wire format) may be sent along; it is
if (billing == null) return BadRequest(); // persisted on the estimate linked to the query
// (Estimate.CommandId == queryId), and the matching validation
billing.Status = QueryStatus.Rejected; // date is stamped — ProviderValidationDate when the provider
dbContext.SaveChanges(User.GetUserId()); // accepts, ClientValidationDate when the client accepts.
return Ok();
}
[HttpPost("query/accept")] [HttpPost("query/accept")]
public IActionResult AcceptQuery(string billingCode, long queryId) public async Task<IActionResult> AcceptQuery(
string billingCode,
long queryId,
[FromBody] QueryAcceptanceRequest? body,
CancellationToken token)
{ {
if (billingCode == null) return BadRequest("billingCode"); var (query, role, error) = await ResolveAndAuthorizeAsync(billingCode, queryId, token);
if (queryId == 0) return BadRequest("queryId"); if (error is not null) return error;
var billing = BillingService.GetBillable(dbContext, billingCode, queryId); if (query is null) return BadRequest(new { Error = "query not found" });
if (billing == null) return BadRequest();
billing.Status = QueryStatus.Accepted; Signature? signature = null;
dbContext.SaveChanges(User.GetUserId()); if (HasSignature(body))
return Ok(); {
// Admins bypass authorization but are neither party of
// the estimate, so they cannot sign.
if (role == ActorRole.Admin)
return BadRequest(new { Error = "admin cannot sign on behalf of a party" });
var estimate = await dbContext.Estimates
.Where(e => e.CommandId == queryId)
.OrderByDescending(e => e.Id)
.FirstOrDefaultAsync(token);
if (estimate is null)
return BadRequest(new { Error = "no estimate linked to this query" });
var type = role == ActorRole.Provider
? SignatureType.Pro
: SignatureType.Client;
var payload = new SignaturePadPayload
{
CoordinateMax = body!.CoordinateMax,
CapturedAtUtc = body.CapturedAtUtc ?? DateTime.UtcNow,
Strokes = body.Strokes!,
};
try
{
var uid = User.GetUserId();
signature = await EstimateSignaturePersister.StageAsync(
dbContext, estimate.Id, type, uid, payload, token);
} }
catch (Exception ex)
{
logger.LogError(ex, "query {QueryId}: signature stage failed", queryId);
return BadRequest(new { Error = "signature stage failed", Detail = ex.Message });
}
if (role == ActorRole.Provider)
estimate.ProviderValidationDate = DateTime.UtcNow;
else
estimate.ClientValidationDate = DateTime.UtcNow;
}
query.Status = QueryStatus.Accepted;
try
{
await dbContext.SaveChangesAsync(User.GetUserId(), token);
}
catch (Exception ex)
{
logger.LogError(ex, "query {QueryId}: accept db write failed", queryId);
return BadRequest(new { Error = "db write failed", Detail = ex.Message });
}
return Ok(new
{
queryId,
status = query.Status.ToString(),
signature = signature is null
? null
: new
{
id = signature.Id,
estimateId = signature.EstimateId,
type = signature.Type.ToString(),
},
});
}
// POST: api/front/query/reject
//
// Either party may reject. A signature is not meaningful on a
// rejection, so one is refused with 400.
[HttpPost("query/reject")]
public async Task<IActionResult> RejectQuery(
string billingCode,
long queryId,
[FromBody] QueryAcceptanceRequest? body,
CancellationToken token)
{
if (HasSignature(body))
return BadRequest(new { Error = "signature not allowed on reject" });
var (query, role, error) = await ResolveAndAuthorizeAsync(billingCode, queryId, token);
if (error is not null) return error;
if (query is null) return BadRequest(new { Error = "query not found" });
query.Status = QueryStatus.Rejected;
await dbContext.SaveChangesAsync(User.GetUserId(), token);
return Ok(new
{
queryId,
status = query.Status.ToString(),
});
}
// Loads the query and checks that the caller is a party to it
// (provider or client) or an admin. Returns the role the caller
// is acting in; Admin is only returned when the caller is
// neither party but is an admin.
private async Task<(IQuery? Query, ActorRole Role, IActionResult? Error)>
ResolveAndAuthorizeAsync(string billingCode, long queryId, CancellationToken token)
{
if (string.IsNullOrWhiteSpace(billingCode))
return (null, ActorRole.None, BadRequest("billingCode"));
if (queryId == 0)
return (null, ActorRole.None, BadRequest("queryId"));
var query = await billing.GetBillAsync(billingCode, queryId);
if (query is null)
return (null, ActorRole.None, BadRequest());
var uid = User.GetUserId();
bool isProvider = query.PerformerId == uid;
bool isClient = query.ClientId == uid;
if (isProvider) return (query, ActorRole.Provider, null);
if (isClient) return (query, ActorRole.Client, null);
if (User.IsInRole(Constants.AdminGroupName))
return (query, ActorRole.Admin, null);
return (null, ActorRole.None, Forbid());
}
private static bool HasSignature(QueryAcceptanceRequest? body)
=> body is not null && body.Strokes is not null && body.Strokes.Length > 0;
}
internal enum ActorRole { None, Provider, Client, Admin }
}
namespace Yavsc.ViewModels.FrontOffice
{
/// <summary>
/// Body of <c>POST /api/v1/front/query/accept</c> and
/// <c>.../reject</c>. The <see cref="Strokes"/> field is optional:
/// when present (non-empty) on accept, it is the PostIt wire-format
/// signature captured with the acceptance. All fields are optional
/// so a caller may post <c>{}</c> to accept/reject without a
/// signature.
/// </summary>
public class QueryAcceptanceRequest
{
/// <summary>
/// Wire-format strokes. See
/// <c>PostIt.Models.SignaturePadData</c>. Null or empty means
/// no signature.
/// </summary>
public int[]? Strokes { get; set; }
public int CoordinateMax { get; set; } = 10_000;
public DateTime? CapturedAtUtc { get; set; }
} }
} }

View file

@ -1,136 +0,0 @@
using System.Security.Claims;
using System.Text.Json;
using Xunit;
using Yavsc.Models.Billing;
using Yavsc.Server.Helpers;
using Yavsc.Server.Models.FileSystem;
namespace Yavsc.Org.Tests;
/// <summary>
/// Tests for the <see cref="EstimateSignatureFileHelper"/> static
/// helper. Scope is intentionally narrow: the file-naming format,
/// the strokes counter, and the on-disk write path. The controller
/// (authz, db persistence, signalR notification) is out of scope
/// for this commit and will get a dedicated integration test once
/// the Yavsc.Api test project is set up.
/// </summary>
public class EstimateSignatureFileHelperTests : IDisposable
{
private readonly string _tempRoot;
private SiteSettings siteSettings;
public EstimateSignatureFileHelperTests()
{
// UserFilesDirName is a process-wide static; we redirect
// it to a per-test temp dir so concurrent tests don't
// collide and the host filesystem is not littered.
_tempRoot = Path.Combine(
Path.GetTempPath(),
"yavsc-sig-tests-" + Guid.NewGuid().ToString("N"));
Directory.CreateDirectory(_tempRoot);
this.siteSettings = new SiteSettings { Blog = _tempRoot };
}
public void Dispose()
{
try { Directory.Delete(_tempRoot, recursive: true); }
catch { /* best effort — the OS will clean Temp eventually */ }
}
[Fact]
public void FileNameFormat_lowercases_type_and_includes_estimateId_and_ticks()
{
var name = EstimateSignatureFileHelper.FileNameFormat(
SignatureType.Pro, 42, 638_000_000_000_000_000L);
Assert.Equal("sign-pro-42-638000000000000000.json", name);
var cli = EstimateSignatureFileHelper.FileNameFormat(
SignatureType.Client, 7, 1L);
Assert.Equal("sign-client-7-1.json", cli);
}
[Theory]
[InlineData(new int[] { }, 0)]
[InlineData(new[] { 1, 100, 200 }, 1)]
[InlineData(new[] { 2, 1, 2, 3, 4 }, 1)]
[InlineData(new[] { 1, 1, 1, 2, 2, 3, 3 }, 2)]
[InlineData(new[] { 0, 1, 2, 3 }, 0)] // malformed k=0: short-circuit
public void ReceiveEstimateSignatureAsync_writes_a_v1_envelope(int[] strokes, int expectedStrokeCount)
{
// We don't read the count back from the helper (it's a
// private method), but the JSON envelope must reflect
// it; this verifies the public behaviour end-to-end.
_ = expectedStrokeCount;
// Arrange
var user = MakeUser("alice");
var payload = new SignaturePadPayload
{
CoordinateMax = 10_000,
CapturedAtUtc = new DateTime(2026, 7, 4, 12, 0, 0, DateTimeKind.Utc),
Strokes = strokes,
};
// Act
var fi = Run(user, 123L, SignatureType.Pro, payload, this.siteSettings);
// Assert: file exists, sits under the user's root, and
// parses as a yavsc.signature/v1 envelope.
var fullPath = Path.Combine(fi.DestDir, fi.FileName);
Assert.True(File.Exists(fullPath), $"missing: {fullPath}");
using var doc = JsonDocument.Parse(File.ReadAllText(fullPath));
var root = doc.RootElement;
Assert.Equal("yavsc.signature/v1", root.GetProperty("format").GetString());
Assert.Equal(10_000, root.GetProperty("coordinateMax").GetInt32());
Assert.Equal(123L, root.GetProperty("estimateId").GetInt64());
Assert.Equal("Pro", root.GetProperty("type").GetString());
Assert.Equal("alice", root.GetProperty("signerName").GetString());
Assert.Equal(expectedStrokeCount, root.GetProperty("strokeCount").GetInt32());
}
[Fact]
public async Task ReceiveEstimateSignatureAsync_rejects_null_payload()
{
var user = MakeUser("bob");
// Capture TestContext.Current.CancellationToken outside the
// lambda so xUnit1051 sees a real CancellationToken argument
// (the lambda body runs on a different stack frame).
var ct = TestContext.Current.CancellationToken;
await Assert.ThrowsAsync<ArgumentNullException>(() =>
EstimateSignatureFileHelper.ReceiveEstimateSignatureAsync(
user, 1L, SignatureType.Pro, payload: null!,
siteSettings, token: ct));
}
[Fact]
public async Task ReceiveEstimateSignatureAsync_rejects_non_positive_estimateId()
{
var user = MakeUser("bob");
var payload = new SignaturePadPayload { Strokes = new[] { 1, 100, 100 } };
var ct = TestContext.Current.CancellationToken;
await Assert.ThrowsAsync<ArgumentOutOfRangeException>(() =>
EstimateSignatureFileHelper.ReceiveEstimateSignatureAsync(
user, 0L, SignatureType.Pro, payload, this.siteSettings, token: ct));
}
// --- helpers ----------------------------------------------------
private static FileReceivedInfo Run(
ClaimsPrincipal user, long estimateId, SignatureType type, SignaturePadPayload payload, SiteSettings siteSettings)
{
// The helper is async; tests that don't care about the
// result can call it sync via .GetAwaiter().GetResult()
// because we know it never throws in the happy path.
return EstimateSignatureFileHelper
.ReceiveEstimateSignatureAsync(user, estimateId, type, payload, siteSettings, CancellationToken.None)
.GetAwaiter().GetResult();
}
private static ClaimsPrincipal MakeUser(string username)
{
return new ClaimsPrincipal(new ClaimsIdentity(
new[] { new Claim(ClaimTypes.Name, username) },
authenticationType: "test"));
}
}

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,29 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace Yavsc.Migrations
{
/// <inheritdoc />
public partial class DropSignatureFilePath : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "FilePath",
table: "Signatures");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<string>(
name: "FilePath",
table: "Signatures",
type: "text",
nullable: false,
defaultValue: "");
}
}
}

View file

@ -17,7 +17,7 @@ namespace Yavsc.Migrations
{ {
#pragma warning disable 612, 618 #pragma warning disable 612, 618
modelBuilder modelBuilder
.HasAnnotation("ProductVersion", "10.0.9") .HasAnnotation("ProductVersion", "10.0.12")
.HasAnnotation("Relational:MaxIdentifierLength", 63); .HasAnnotation("Relational:MaxIdentifierLength", 63);
NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
@ -1521,10 +1521,6 @@ namespace Yavsc.Migrations
b.Property<long>("EstimateId") b.Property<long>("EstimateId")
.HasColumnType("bigint"); .HasColumnType("bigint");
b.Property<string>("FilePath")
.IsRequired()
.HasColumnType("text");
b.Property<string>("SignerId") b.Property<string>("SignerId")
.IsRequired() .IsRequired()
.HasColumnType("text"); .HasColumnType("text");
@ -3959,8 +3955,8 @@ namespace Yavsc.Migrations
modelBuilder.Entity("Yavsc.Models.BlogSpotPublication", b => modelBuilder.Entity("Yavsc.Models.BlogSpotPublication", b =>
{ {
b.HasOne("Yavsc.Models.Blog.BlogPost", "Post") b.HasOne("Yavsc.Models.Blog.BlogPost", "Post")
.WithMany() .WithOne("Publication")
.HasForeignKey("PostId") .HasForeignKey("Yavsc.Models.BlogSpotPublication", "PostId")
.OnDelete(DeleteBehavior.Cascade) .OnDelete(DeleteBehavior.Cascade)
.IsRequired(); .IsRequired();
@ -4614,6 +4610,9 @@ namespace Yavsc.Migrations
b.Navigation("Comments"); b.Navigation("Comments");
b.Navigation("Publication")
.IsRequired();
b.Navigation("Tags"); b.Navigation("Tags");
}); });

View file

@ -1,158 +0,0 @@
#nullable enable annotations
using System.Security.Claims;
using System.Text;
using System.Text.Json;
using Microsoft.AspNetCore.Http;
using Yavsc.Models.Billing;
using Yavsc.Server.Models.FileSystem;
namespace Yavsc.Server.Helpers;
/// <summary>
/// Filesystem counterpart of <see cref="Signature"/>: writes
/// the wire-format JSON payload next to the user's other files,
/// under <c>signatures/</c>, and updates the user's disk quota.
///
/// This is the JSON counterpart of the legacy
/// <c>ReceiveProSignatureAsync</c> method, which stored
/// <c>sign-{billingCode}-{signType}-{estimateId}.png</c> blobs.
/// We don't reuse that helper because (a) the wire format is no
/// longer a binary image, (b) there's no <c>billingCode</c> on
/// a freshly signed estimate in our model, and (c) the legacy
/// helper takes an <see cref="IFormFile"/> whereas our pipeline
/// decodes a JSON body upstream of the controller and passes
/// <see cref="SignaturePadPayload"/> in directly.
/// </summary>
public static class EstimateSignatureFileHelper
{
/// <summary>
/// Sub-directory under the user's root where signature
/// payloads live. Kept short to leave room in PATH_MAX on
/// legacy filesystems; the rest of the filename is
/// <c>sign-{type}-{estimateId}-{utcTicks}.json</c>.
/// </summary>
public const string SignaturesSubdir = "signatures";
/// <summary>
/// Format string for signature file names. Public so the
/// migration and the admin tools can list by pattern.
/// </summary>
public static string FileNameFormat(SignatureType type, long estimateId, long utcTicks)
=> $"sign-{type.ToString().ToLowerInvariant()}-{estimateId}-{utcTicks}.json";
/// <summary>
/// Persist a signature wire payload to disk. Returns the
/// file info (relative path under the user's root) suitable
/// for storing in <see cref="Signature.FilePath"/>; the
/// caller is responsible for the database write.
/// </summary>
/// <param name="user">Signed-in user. Their
/// <c>Identity.Name</c> locates the disk root via
/// <see cref="AbstractFileSystemHelpers.UserFilesDirName"/>.
/// </param>
/// <param name="estimateId">Estimate this signature
/// attaches to. Used in the file name for human inspection
/// and to support multiple versions over time.</param>
/// <param name="type">Provider or client signature.</param>
/// <param name="payload">Decoded wire payload (strokes +
/// coordinateMax + capturedAtUtc). Already validated
/// upstream.</param>
/// <param name="token">Cancellation token forwarded to
/// the file write.</param>
public static async Task<FileReceivedInfo> ReceiveEstimateSignatureAsync(
this ClaimsPrincipal user,
long estimateId,
SignatureType type,
SignaturePadPayload payload,
SiteSettings siteSettings,
CancellationToken token = default)
{
if (user is null) throw new ArgumentNullException(nameof(user));
if (payload is null) throw new ArgumentNullException(nameof(payload));
if (estimateId <= 0) throw new ArgumentOutOfRangeException(nameof(estimateId));
// Ensure the user has a /signatures/ sub-directory we can
// write to. EnsureDestinationDirectory throws on invalid
// paths and creates the directory on the way; the
// SignaturesSubdir constant is a server-controlled value
// (not user-derived), so we skip the IsValidYavscPath
// check that ReceiveUserFile performs on user-supplied
// subpaths.
var root = user.EnsureDestinationDirectory(SignaturesSubdir, siteSettings);
var fileName = FileNameFormat(type, estimateId, DateTime.UtcNow.Ticks);
var fullPath = Path.Combine(root, fileName);
var envelope = new
{
format = "yavsc.signature/v1",
coordinateMax = payload.CoordinateMax,
capturedAtUtc = payload.CapturedAtUtc,
estimateId,
type = type.ToString(),
// Identity.Name is the username; we keep the wire
// payload keyed on the username rather than the
// numeric/guid Id so disk-side human inspection
// (e.g. cat sign-pro-1234-...json) is self-evident.
signerName = user.Identity?.Name,
strokes = payload.Strokes,
strokeCount = CountStrokes(payload.Strokes),
};
var json = JsonSerializer.Serialize(envelope, new JsonSerializerOptions { WriteIndented = true });
await File.WriteAllTextAsync(fullPath, json, Encoding.UTF8, token).ConfigureAwait(false);
// Quota update is the controller's responsibility: the
// helper has no DbContext access, and a ClaimsPrincipal
// is not an ApplicationUser. The controller looks up
// the user by Identity.Name and bumps DiskUsage after
// a successful database write.
return new FileReceivedInfo(root, fileName);
}
private static int CountStrokes(int[] strokes)
{
int n = 0;
for (int i = 0; i < strokes.Length;)
{
int k = strokes[i];
if (k <= 0) break;
n++;
i += 1 + 2 * k;
}
return n;
}
}
/// <summary>
/// Wire payload accepted by the signature endpoint and
/// persisted by <see cref="EstimateSignatureFileHelper"/>.
/// Mirrors <c>PostIt.Models.SignaturePadData</c>'s JSON shape
/// (without the disk-only envelope fields) so the two sides
/// stay trivially compatible.
/// </summary>
public class SignaturePadPayload
{
/// <summary>
/// Normalised coordinate upper bound. Must be
/// <c>PostIt.Models.SignaturePadData.CoordinateMax</c>
/// (10_000) today; declared as a property so a future
/// resolution change can be replayed against the same
/// wire format.
/// </summary>
public int CoordinateMax { get; set; } = 10_000;
/// <summary>
/// Client-reported capture time. The server may ignore
/// this for ordering (UTC now is the truth) but keeps it
/// for round-trip display.
/// </summary>
public DateTime CapturedAtUtc { get; set; }
/// <summary>
/// Wire strokes. See
/// <c>PostIt.Models.SignaturePadData</c> for the format.
/// </summary>
public int[] Strokes { get; set; } = Array.Empty<int>();
}

View file

@ -0,0 +1,111 @@
#nullable enable annotations
using System.Security.Claims;
using Microsoft.EntityFrameworkCore;
using Yavsc.Models;
using Yavsc.Models.Billing;
namespace Yavsc.Server.Helpers;
/// <summary>
/// Shared signature-persistence logic for estimates, extracted from
/// <c>BillingController.Sign</c>. Stages the <see cref="Signature"/>
/// row (find-or-add by <c>(EstimateId, Type)</c>) for the PostIt
/// wire-format payload — but does <b>not</b> call
/// <c>SaveChanges</c>. The caller owns the transaction so it can
/// combine the signature write with its own changes (e.g. setting a
/// validation date or a query status) in a single
/// <c>SaveChanges</c>.
///
/// <para>
/// The <see cref="Signature.Strokes"/> column is the source of
/// truth; there is no on-disk copy, so no disk-quota accounting is
/// involved.
/// </para>
/// </summary>
public static class EstimateSignaturePersister
{
/// <summary>
/// Stage a signature for <paramref name="estimateId"/> signed by
/// <paramref name="signerId"/> as <paramref name="type"/>. Upserts
/// the <see cref="Signature"/> row for the
/// <c>(EstimateId, Type)</c> pair, writing the wire payload
/// (<c>Strokes</c>, <c>CoordinateMax</c>, <c>CapturedAtUtc</c>)
/// and <paramref name="signerId"/>. Returns the staged
/// <see cref="Signature"/>; the caller persists with
/// <c>SaveChanges</c>.
/// </summary>
public static async Task<Signature> StageAsync(
ApplicationDbContext db,
long estimateId,
SignatureType type,
string signerId,
SignaturePadPayload payload,
CancellationToken token = default)
{
if (db is null) throw new ArgumentNullException(nameof(db));
if (payload is null) throw new ArgumentNullException(nameof(payload));
if (estimateId <= 0) throw new ArgumentOutOfRangeException(nameof(estimateId));
// Find-or-add: the (EstimateId, Type) pair is unique, so a
// second submission for the same side of the estimate
// replaces the previous signature. EF translates this into a
// single UPDATE when the row exists and an INSERT otherwise;
// the unique index in ApplicationDbContext is the
// database-level guarantee that the contract holds if two
// requests race.
var signature = await db.Signatures
.FirstOrDefaultAsync(s => s.EstimateId == estimateId && s.Type == type, token)
.ConfigureAwait(false);
if (signature is null)
{
signature = new Signature
{
EstimateId = estimateId,
SignerId = signerId,
Type = type,
};
db.Signatures.Add(signature);
}
signature.SignerId = signerId;
signature.CoordinateMax = payload.CoordinateMax;
signature.Strokes = payload.Strokes;
signature.CapturedAtUtc = payload.CapturedAtUtc;
return signature;
}
}
/// <summary>
/// Wire payload accepted by the signature path and staged by
/// <see cref="EstimateSignaturePersister"/>. Mirrors
/// <c>PostIt.Models.SignaturePadData</c>'s JSON shape (without the
/// disk-only envelope fields) so the two sides stay trivially
/// compatible.
/// </summary>
public class SignaturePadPayload
{
/// <summary>
/// Normalised coordinate upper bound. Must be
/// <c>PostIt.Models.SignaturePadData.CoordinateMax</c>
/// (10_000) today; declared as a property so a future
/// resolution change can be replayed against the same
/// wire format.
/// </summary>
public int CoordinateMax { get; set; } = 10_000;
/// <summary>
/// Client-reported capture time. The server may ignore
/// this for ordering (UTC now is the truth) but keeps it
/// for round-trip display.
/// </summary>
public DateTime CapturedAtUtc { get; set; }
/// <summary>
/// Wire strokes. See
/// <c>PostIt.Models.SignaturePadData</c> for the format.
/// </summary>
public int[] Strokes { get; set; } = Array.Empty<int>();
}

View file

@ -7,8 +7,8 @@ namespace Yavsc.Models.Billing;
/// <summary> /// <summary>
/// One captured signature, attached to a single /// One captured signature, attached to a single
/// <see cref="Estimate"/>. Multiple versions are allowed per /// <see cref="Estimate"/>. Multiple versions are allowed per
/// (EstimateId, Type, SignerId) tuple — the controller reads /// (EstimateId, Type, SignerId) tuple — the controller reads the
/// the most recent when asked. The wire-format payload is the /// most recent when asked. The wire-format payload is the
/// same <c>int[]</c> shape PostIt produces (see /// same <c>int[]</c> shape PostIt produces (see
/// <c>PostIt.Models.SignaturePadData</c>): a length-prefixed /// <c>PostIt.Models.SignaturePadData</c>): a length-prefixed
/// sequence of strokes, each stroke being /// sequence of strokes, each stroke being
@ -16,13 +16,12 @@ namespace Yavsc.Models.Billing;
/// CoordinateMax]</c>. /// CoordinateMax]</c>.
/// ///
/// <para> /// <para>
/// Why a separate table (instead of a JSON column on /// The <see cref="Strokes"/> column is the source of truth: the
/// <see cref="Estimate"/>): the jalon 1 spec calls for at least /// signature is captured and stored entirely in-app (PostIt) and
/// two distinct signatures per estimate cycle (provider /// round-tripped as JSON, with no on-disk copy. A dedicated table
/// validation + client agreement) and the audit value of /// (instead of a JSON column on <see cref="Estimate"/>) keeps the
/// preserving superseded versions. A dedicated table also keeps /// <see cref="Estimate"/> row narrow for list views and preserves
/// the <see cref="Estimate"/> row narrow, which matters for /// superseded versions for audit.
/// list views.
/// </para> /// </para>
/// </summary> /// </summary>
public class Signature public class Signature
@ -75,16 +74,4 @@ public class Signature
public int[] Strokes { get; set; } = Array.Empty<int>(); public int[] Strokes { get; set; } = Array.Empty<int>();
public DateTime CapturedAtUtc { get; set; } public DateTime CapturedAtUtc { get; set; }
/// <summary>
/// Path to the JSON-serialised wire payload on disk,
/// relative to <c>UserFilesDirName</c>. The disk copy is the
/// source of truth for the wire bytes; the <see cref="Strokes"/>
/// column is a denormalised index for queries. They are
/// written together in the same transaction by the
/// controller; the migration should keep them in sync
/// through <c>ApplicationDbContext.SaveChanges</c>.
/// </summary>
[Required]
public string FilePath { get; set; }
} }