ConfigureBillingService() walks AppDomain.CurrentDomain.GetAssemblies()
and calls Assembly.GetTypes() on each. If any of the loaded assemblies
has a type that fails to resolve (a flaky dependency, an AddOn with a
broken reference, a test dependency that's been rewritten after compile),
GetTypes() throws ReflectionTypeLoadException (or, less commonly,
FileNotFoundException / TypeLoadException for the assembly itself).
In CI on the forgejo-runner (and especially in test discovery under
xunit v3), one such assembly is loaded somewhere between test runs and
silently throws. The exception is not handled, so:
1. Collections are Cleared at the top of ConfigureBillingService().
2. The reflection loop throws before reaching the
RegisterBilling<HairCutQuery/HairMultiCutQuery/RdvQuery> calls.
3. BillingService.Billing ends up empty (Count = 0).
4. The second ConfigureBillingService() call sees the same assembly
loaded (xunit v3 keeps the AppDomain warm for the whole suite),
throws identically, and the test
Yavsc.BillingServiceTests.ConfigureBillingService_CanBeCalledTwiceWithoutThrowing
fails with 'Assert.Equal() Failure: Expected 3, Actual 0'.
Fix: catch ReflectionTypeLoadException and use the partial
.Types() list (the successfully-resolved subset), and use a
broader catch (with continue) for any other assembly-level
load failure. The lost user-settings types are not material;
they are derived from ApplicationDbContext in a separate loop
right after, and the RegisterBilling<>() calls that populate
BillingService.Billing run last, after both reflective phases
have completed best-effort.
The test still passes locally because the local test environment
loads a clean set of assemblies; only the CI runner (with its
extra test-time tooling) hits this path.
LoadClientAsync(id) on ClientController used to trip an
IndexOutOfRangeException at the InMemory shaper for any
.Include() of one of three Client navs: RedirectUris,
AllowedScopes, AllowedGrantTypes. Five other Client navs (with
the same EF shape and the same application-level config) worked
fine.
Bisection pointed at the InMemory provider; that hypothesis was
wrong. The real cause is in ApplicationDbContext.OnModelCreating:
yavsc was redeclaring the HasOne<Client>().WithMany(...).
HasForeignKey(e => e.ClientId) for all eight Client* navs. The
same relation is already declared (more completely, with
.IsRequired().OnDelete(DeleteBehavior.Cascade)) by
IdentityServer8's ConfigureClientStore via ModelBuilderExtensions.
The redundant mapping on three specific entities — ClientScope,
ClientRedirectUri, ClientGrantType — interacts with the InMemory
provider's shaper in a way that throws IndexOutOfRange. Removing
the redundancy fixes it.
This commit also walks back b12c272d:
- Drops .AsSplitQuery() from LoadClientAsync (no longer needed
for the InMemory shaper, and the Postgres path it was a hedge
against was a false alarm — there is no Postgres production
bug here, only an InMemory shaper quirk surfaced by the
redundant mapping).
- Removes the 9 Bisect_*_alone tests that were the artefact of
the provider-hypothesis phase. They pointed at the right
entities but for the wrong reason.
- Keeps EditRedirectUris_GET_after_add_lists_both_uris as the
end-to-end regression sentinel: with the fix in place, it
loads a Client with two RedirectUris and asserts both are
rendered. Without the fix, it fails with IndexOutOfRange.
Tout billet a un auteur, tout commentaire a un auteur. On aligne la
base sur ce contrat (Postgres) en droppant les orphelins existants
puis en remplaçant les FK en cascade par des FK Restrict.
- ApplicationDbContext: fluent pour BlogPost.Author et Comment.Author
en DeleteBehavior.Restrict.
- ApplicationUser: ajoute la nav inverse BlogComments (manquait,
EF aurait sinon créé une shadow FK).
- Migration 20260711173717_EnforceBlogAuthorFKs: Up purge les
Comment/BlogSpot dont l'AuthorId n'existe plus, log le volume,
puis drop+add des FK. Down laisse la cascade (état pré-migration).
Le code applicatif (BlogSpotService.Details) s'appuiera sur cette
contrainte dans un commit séparé.
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.
Narrow ISmtpClient to the four operations MailSender actually uses,
behind a Yavsc.Interfaces.ISmtpClientFactory. Production wires
MailKitSmtpClient (SmtpClientFactory); tests wire a recording fake
(RecordingSmtpClientFactory). The fake is pre-registered in
WebServerFixture so SMTP calls are short-circuited; the EMailling
test now asserts the Connect -> Authenticate -> Send -> Disconnect
sequence. Yavsc.Abstract stays free of MailKit/MimeKit.
Move product-local package versions out of the root Directory.Packages.props
into per-product props files under src/<Product>/. The root file now only
contains versions for packages declared by two or more top-level products,
which is the actual shared set.
Each per-product Directory.Packages.props imports the root via
GetPathOfFileAbove so that the shared versions are inherited; this is
necessary because the .NET SDK picks the closest Directory.Packages.props
in the hierarchy and does not merge multiple ones.
Per-product file contents:
- src/cli/ Microsoft.AspNetCore.Razor.Language,
Microsoft.Extensions.{CommandLineUtils,Configuration,Hosting}
- src/PostIt/ Avalonia* and CommunityToolkit.Mvvm
- src/PostIt.Tests/ Avalonia.Headless{,XUnit}
- src/Yavsc.Org/ AsciiDocSharp*, Google.Apis.Compute.v1,
HigginsSoft.IdentityServer8.AspNetIdentity,
IdentityServer8.EntityFramework.Storage,
IdentityServer8.Security, IdentityServer8.Storage,
Microsoft.AspNetCore.Antiforgery, Authentication.Google,
Diagnostics.EntityFrameworkCore, Mvc.NewtonsoftJson,
SignalR, EntityFrameworkCore.Tools, Swashbuckle,
System.Security.Cryptography.Pkcs, YamlDotNet
- src/Yavsc.Org.Tests/ Microsoft.AspNetCore.Hosting,
Extensions.Caching.Memory, Options,
Options.ConfigurationExtensions,
Selenium.WebDriver, xunit.v3.{common,extensibility.core}
- src/Yavsc.Server/ Anthropic.SDK, Google.Apis.Calendar.v3,
Magick.NET-Q8-AnyCPU, MailKit, MimeKit,
Microsoft.AspNetCore.Http.Features, StaticFiles,
EntityFrameworkCore.SqlServer,
Npgsql.EntityFrameworkCore.PostgreSQL,
PayPalMerchantSDK, pazof.rules, RazorEngine.NetCore
- src/Yavsc.Web/ IdentityModel.AspNetCore
No per-product file is created for Yavsc.Api, Yavsc.Blogs, Yavsc.Abstract,
or templateWeb: Api and Blogs only declare the shared JwtBearer, Abstract
and templateWeb declare no package references at all.
Also includes a minor cosmetic update to FirstUIStript.cs (Firefox -> Chrome
driver, dedent, comment header). Tests previously failing on DataProtection
keyset / SMTP were unrelated environment issues (resolved by fixing the
SMTP password locally).
The Site:Audience setting was conflating two distinct concepts: an OAuth
JWT audience (a single resource identifier) and a CORS allow-list (an
array of origins). Collapsing them caused several latent bugs:
- OAuth/JWT validation expected a single string while CORS WithOrigins
accepts an array.
- Password-reset callback URLs and OAuth client RedirectUri/Origin were
being built from what was meant to be an audience identifier, not a
base URL.
- Yavsc.Org's main CORS policy was hardcoded to '*', with no way to
restrict it without code changes.
Changes:
- SiteSettings.Audience (string) replaced with CorsAllowedOrigins
(IList<string>).
- OAuth JWT Authority still reads Site:Authority; Audience now reads
Site:ExternalUrl (Org only; Api/Blogs use ValidateAudience=false).
- MailSender and AccountController build reset-callback URLs from
Site:ExternalUrl.
- ClientController uses Site:ExternalUrl for OAuth RedirectUri/Origin
defaults on newly created clients.
- Yavsc.Api and Yavsc.Blogs now read CORS origins from
Site:CorsAllowedOrigins instead of hardcoded URLs.
Add shared AddYavscCors / AddYavscJwtBearer extension methods in
Yavsc.Server/Helpers/ServiceExtensions.cs to enforce a single
configuration contract across all runtime services (Api, Blogs, Org).
Fails closed when CorsAllowedOrigins is empty; fails fast at startup
when Site:Authority is missing.
Remove obsolete ConfigurationHelpers.GetAudience (no remaining callers).
Local appsettings-*.json files (which carry deployment-specific values
and are gitignored) must be updated to add Site:CorsAllowedOrigins.