Adds a new JSON-bodied signature endpoint as a sibling of the
legacy PNG-based prosign/clisign routes. The legacy flow stays
intact: the TeX invoice templates (Bill_tex.cshtml,
Estimate_tex.cshtml) still consume the sign-{billingCode}-{id}.png
files the old endpoints write, and the new endpoint writes to a
distinct /signatures/ tree under UserFilesDirName. A future
migration commit will regenerate PNGs from the JSON payload and
decommission the PNG flow.
Scope
- New Signature entity (Yavsc.Server/Models/Billing/Signature.cs)
with FK to Estimate, FK to ApplicationUser (Signer), Type
(Pro/Client) enum, CoordinateMax (default 10_000), int[] Strokes
(native Npgsql mapping), CapturedAtUtc, FilePath. Multiple
versions per (EstimateId, Type) are allowed; the controller
reads the most recent.
- New Estimate.Signatures nav collection (InverseProperty) so the
composite index covers both sides of the relation.
- New DbSet<Signature> Signatures + composite index
(EstimateId, Type, CapturedAtUtc DESC) in ApplicationDbContext
OnModelCreating. DeleteBehavior.Cascade on Estimate deletion
cleans up signatures automatically.
- New EstimateSignatureFileHelper (Server/Helpers) with
ReceiveEstimateSignatureAsync(user, estimateId, type, payload).
Writes a yavsc.signature/v1 JSON envelope to
UserFilesDirName/{user}/signatures/sign-{type}-{estimateId}-{ticks}.json.
Quota update lives in the controller, not the helper, because
the helper has no DbContext access.
- New endpoint POST /api/bill/estimate/{id:long}/sign on
BillingController. Authz is body-driven (the bearer token is the
PostIt OAuth client, not the end user, so signerUserId is in
the JSON body, validated against Estimate.OwnerId/ClientId).
Returns 201 Created with the new Signature's metadata.
Plumbing
- SignatureSubmission (body type) lives next to BillingController
in the same file — small enough to keep colocated.
- The legacy prosign/clisign routes are untouched. They keep
the IFormFile PNG contract; the new endpoint is the JSON
counterpart.
Tests
- New EstimateSignatureFileHelperTests in Yavsc.Org.Tests
(8 tests, all green): filename format incl. lowercase type and
ticks, envelope v1 round-trip (parsed via JsonDocument, not
text matching), null payload rejected, non-positive
estimateId rejected. Disk side effects are isolated to a
per-test temp root via AbstractFileSystemHelpers.UserFilesDirName.
- Yavsc.Org.Tests full suite: 29/29 green.
- PostIt.Tests: 57/57 green (untouched by this commit).
- Builds: Yavsc.Server, Yavsc.Api, Yavsc.Org, Yavsc.Org.Tests
all compile clean.
Out of scope
- EF migration: the Signatures table doesn't exist in the
database yet. The migration is intentionally a separate
commit so the generated SQL can be reviewed against the
composite index and the int[] column type before it touches
any prod database. Until the migration lands, the new
endpoint will 500 on SaveChanges; the [DEV] button in
PostIt is the only call site, so this is acceptable.
- SignalR handler that opens the signature page on a
'devis received' push — commit 4.
134 lines
5.2 KiB
C#
134 lines
5.2 KiB
C#
using System;
|
|
using System.IO;
|
|
using System.Security.Claims;
|
|
using System.Text.Json;
|
|
using System.Threading;
|
|
using System.Threading.Tasks;
|
|
using Xunit;
|
|
using Yavsc.Models;
|
|
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;
|
|
|
|
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);
|
|
AbstractFileSystemHelpers.UserFilesDirName = _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);
|
|
|
|
// 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");
|
|
await Assert.ThrowsAsync<ArgumentNullException>(() =>
|
|
EstimateSignatureFileHelper.ReceiveEstimateSignatureAsync(
|
|
user, 1L, SignatureType.Pro, payload: null!));
|
|
}
|
|
|
|
[Fact]
|
|
public async Task ReceiveEstimateSignatureAsync_rejects_non_positive_estimateId()
|
|
{
|
|
var user = MakeUser("bob");
|
|
var payload = new SignaturePadPayload { Strokes = new[] { 1, 100, 100 } };
|
|
await Assert.ThrowsAsync<ArgumentOutOfRangeException>(() =>
|
|
EstimateSignatureFileHelper.ReceiveEstimateSignatureAsync(
|
|
user, 0L, SignatureType.Pro, payload));
|
|
}
|
|
|
|
// --- helpers ----------------------------------------------------
|
|
|
|
private static FileReceivedInfo Run(
|
|
ClaimsPrincipal user, long estimateId, SignatureType type, SignaturePadPayload payload)
|
|
{
|
|
// 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, CancellationToken.None)
|
|
.GetAwaiter().GetResult();
|
|
}
|
|
|
|
private static ClaimsPrincipal MakeUser(string username)
|
|
{
|
|
return new ClaimsPrincipal(new ClaimsIdentity(
|
|
new[] { new Claim(ClaimTypes.Name, username) },
|
|
authenticationType: "test"));
|
|
}
|
|
}
|