Compare commits

...
49 Commits
Author SHA1 Message Date
Stefan 24b94b05c2 Add invoice provision-period PDF handling
Playwright Tests / test (push) Canceled after 0s
Normalize invoice provision periods in PDF generation, switch admin labels between single-date and range modes, add end-to-end tests, update package and frontend bundle versions, and note the invoice lifecycle docs update.
2026-10-09 12:03:06 +02:00
StefanandClaude Opus 5.5 4443bba63c fix(forms): never submit an invalid form; unlock password-change dialog
Playwright Tests / test (push) Canceled after 0s
serializeObject() returns null for an invalid form, but most callers sent
the result anyway. Add $.fn.rejectInvalid (stop the spinner, let the
browser report the offending field) and use it at every call site.

Bugs fixed:
- Password change: on a mismatch the spinner kept running, and the custom
  validity on "npw" was never cleared, so the browser blocked every later
  submit even after the passwords matched. It is now cleared as soon as
  either password field is edited.
- sendForm: options.complete referenced an undefined jqXHR (ReferenceError)
  and an invalid form was posted with partial data.
- login.send: an invalid form threw a TypeError on qs.loginaccount.
- postXT({ form }): an invalid form was posted with empty data.
- Form dialogs without required fields had no "Bitte überprüfen Sie Ihre
  Eingaben" note, so a rejected submit showed nothing.

Hardening: null guards for the user, user-group, re-login, SMS password,
admin specialties and report-parameter submits.

Add e2e_tests/form-validation.cjs covering each path against the real
scripts; it fails on the previous code.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 18:11:05 +02:00
StefanandClaude Opus 5.5 4145ab699c fix(invoices): Leistungsdatum editor lost or mis-formatted the service date
Playwright Tests / test (push) Canceled after 0s
- serializeObject: an empty optional date field was run through parseDt,
  became null and invalidated the whole form, so leaving "bis" empty made
  dlgform call success with only loginaccount and wiped the stored period.
  Empty optional date fields are now sent as ''.
- dlgform: an invalid form no longer calls success with partial data; it
  stops the spinner and keeps the dialog open.
- eProvisionPeriod: the date inputs return yyyy-MM-dd, which was saved
  verbatim; it is now stored as German dd.MM.yyyy as documented (PDF,
  eRechnung mapping). ISO values saved by the old editor are accepted when
  reopening, so their fields are prefilled instead of empty.
- Add a Playwright regression test (e2e_tests/invoice-provisionperiod.cjs)
  driving the real form builder, serializer and dialog submit handler.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 18:04:03 +02:00
Stefan e2faf52cd2 chore: Update submodule commits for OCORE and OCORE_web
Playwright Tests / test (push) Canceled after 0s
2026-10-06 16:41:08 +02:00
StefanandClaude Fable 5.1 546648533b fix(drafts): serialize draft session flushes and register reminders via fds__setReminder
Playwright Tests / test (push) Canceled after 0s
Make invoice/reminder live-draft edits concurrency-safe with an explicit
DraftFlushResult, add the fds__setReminder stored procedure, and harden the
date helper functions. Includes concurrency and SQL contract tests.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-01 13:57:40 +02:00
StefanandClaude Opus 5.5 d0002ccdad fix(invoices): send invoice amounts as exact cent-rounded decimals
Playwright Tests / test (push) Canceled after 0s
Stornorechnung saves still failed: the VAT amount was formatted invariantly
("-615.3757999999999") and re-parsed under the server's de-DE culture as
-6153757999999999, overflowing numeric(10,3) in fds__setInvoice.

- FdsInvoiceData.BuildInvoiceParams: amount params are decimal(10,x) via
  AmountParam/ParseAmount (culture-independent, rounded to cents); amounts
  beyond +/-9,999,999.99 throw InvoiceAmountOutOfRangeException.
- InvoiceService.RegisterInvoiceAsync: refuses such an invoice with a logged
  error (EventId InvoiceAmountRejected) instead of storing a wrong amount.
- Invoice editor: vat_val is cent-rounded decimal, no float noise.
- Tests: InvoiceAmountParamTests replaces the float-based Storno test.
- Bump Azure.Storage.Blobs 12.30.0; OCORE/OCORE_web submodules (AngleSharp
  1.8.3, Azure.Storage.Files.Shares 12.28.0).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-01 10:57:41 +02:00
StefanandClaude Opus 4.8 0a8384ab33 fix(invoices): Stornorechnung save failed on negative totals
Customer log: /do/inv/dsave failed with "Error converting data type float to
numeric" followed by "Cannot insert the value NULL into column 'InvId'".

- Bump OCORE: SQL_Float/SQL_Decimal string overloads parsed negatives via the
  de-DE server culture ("-19.95" -> -1995); float noise overflowed
  numeric(10,3), so fds__setInvoice never ran.
- InvoiceService: the header save batch now stops with a clear THROW when
  fds__createInvoice/fds__setInvoice yields no @Id, instead of failing again
  in fds__createInvoice_Details and burying the real error. Batch SQL
  extracted to BuildRegisterInvoiceSql for testing.
- Tests: signed/culture parsing under de-DE, Storno param build against the
  proc numeric limits, and the batch guard ordering.
- hlp: read-only script to find invoice/reminder amounts stored scaled up
  by the bug.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-10-01 10:07:13 +02:00
Stefan c741535aec feat: Implement mfr truncation fix and enhance invoice handling
Playwright Tests / test (push) Canceled after 0s
- Added a new SQL script to address the "String or binary data would be truncated" error in mfr sync by widening the Note fields in service objects and companies to NVARCHAR(MAX).
- Updated the table types and associated stored procedures to accommodate the changes, ensuring compatibility with existing data.
- Queued a re-sync for previously rejected rows due to truncation issues.
- Created application logins for fuchs_fds and fuchs_web with placeholder passwords for initial setup.
- Introduced functions for invoice backdating and invoice date retrieval to streamline invoice processing.
- Developed a password generation function with customizable parameters for length and character types.
- Added a random number generation function to support password creation.
2026-09-30 16:56:08 +02:00
Stefan 7357f60120 chore: Update submodule commits for OCORE and OCORE_web
Playwright Tests / test (push) Canceled after 0s
2026-09-30 10:20:29 +02:00
Stefan 63b3683725 Bump dependencies and submodule revisions
Update solution-wide package versions and advance OCORE, OCORE_Charting, and OCORE_web submodules.
2026-09-30 10:19:58 +02:00
Stefan a36f0d859d feat: Implement secret management using Azure Key Vault and DPAPI across OCORE and OCORE_web
Playwright Tests / test (push) Canceled after 0s
2026-09-22 21:49:49 +02:00
StefanandClaude Opus 4.8 4bf56c9699 chore: Update OCORE submodules and align package versions
Playwright Tests / test (push) Canceled after 0s
Pull OCORE, OCORE_web and OCORE_web_pdf to their latest main commits.
OCORE bumped MimeKit to 4.18.1 and Microsoft.Data.SqlClient to 7.1.0,
which made restore fail with NU1605 package-downgrade errors in Fuchs
and Fuchs_DataService; raise both pins to match.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-09-22 21:48:46 +02:00
Stefan a334497c82 feat: Add documentation for secret management using Azure Key Vault and DPAPI
Playwright Tests / test (push) Canceled after 0s
2026-09-18 14:14:56 +02:00
Stefan 65034ba2e8 docs: Update admin-system-status documentation to include detailed card descriptions and data sources
Playwright Tests / test (push) Canceled after 0s
2026-09-18 13:44:59 +02:00
Stefan e628454030 fix: Adjust banking transactions function to include manual credits in payment calculations
Playwright Tests / test (push) Canceled after 0s
2026-09-18 12:33:47 +02:00
Stefan 9569c1b65e feat: Enhance invoice file backup procedure to include JSON snapshots
Playwright Tests / test (push) Canceled after 0s
- Updated the stored procedure `fds__admin_backupAndClearInvoiceFile.sql` to back up not only the invoice file but also JSON snapshots of related data (invoices, items, details, service requests) to ensure consistency and traceability.
- Modified the table structure of `fds__invoice_file_backups` to accommodate new JSON columns for storing these snapshots.
- Added an ADR (0030) document to outline the decision and rationale behind these changes, emphasizing the importance of maintaining a consistent set of data alongside the backed-up file.
2026-09-17 22:39:01 +02:00
Stefan 249a945f5e feat: Implement admin-only specialties management interface
Playwright Tests / test (push) Canceled after 0s
- Added MailerAttachmentPolicy for validating email attachments against server rules.
- Introduced fis.specialties.js for managing hidden maintenance actions via an admin interface.
- Created fis.specialties.scss for styling the specialties management UI.
- Added German localization for specialties management in fis.specialties_txt_de.js.
- Developed CSS for specialties management in fis.specialties.css.
- Implemented server-side stored procedure fds__admin_backupAndClearInvoiceFile for backing up and clearing invoice files.
- Created fds__invoice_file_backups table to store backup records of invoice files.
2026-09-17 21:17:23 +02:00
Stefan 86f6a60860 feat: implement set-based invoice payment calculations and enhance email logging configuration
Playwright Tests / test (push) Canceled after 0s
2026-09-16 22:31:27 +02:00
Stefan 944a5bb442 Refactor document name sanitization for consistent filename generation
- Introduced FdsDocumentNames class to centralize the logic for sanitizing document names used in downloads.
- Updated FdsMfr to utilize the new SafeFileName method for generating safe filenames.
- Enhanced documentation for FdsDocumentNames and its methods to clarify usage and purpose.
- Added global.json file to configure testing framework settings.
2026-09-16 22:18:42 +02:00
Stefan a60d4c2eaf fix: update mfr__updt__companies procedure and mfr__tt__companies type for improved data handling 2026-09-16 21:52:53 +02:00
Stefan dcfc459693 feat: update launch and task configurations for improved debugging and release builds 2026-09-16 21:22:10 +02:00
StefanandClaude Opus 5 4cac109283 fix: detect no-op document stores via OUTPUT rowset; sanitise regenerated filename
Follow-up to 99bf533 from code review:

- The AffectedRows == 0 guard in Store{Invoice,Reminder}DocumentFileAsync
  was dead: fds__setInvoiceFile / fds__setReminderFile run SET NOCOUNT ON,
  so ExecuteNonQuery returns -1 whether or not the UPDATE ... WHERE [file]
  IS NULL matched. Both procs already return the updated Id through their
  OUTPUT clause, so read that result set (getSQLDatatable_async) instead:
  empty rowset = no-op store, logged, and the blob upload / FileCreated
  event are skipped rather than reporting unpersisted bytes as success.
- inv/rdoc's regenerate path built the download name from the raw
  DocumentName; a user-typed title with '/' truncated FileInfo.Name.
  SafeDocumentFileName strips editor markup and every invalid filename
  char, and the path no longer goes through FileInfo at all.
- Reminder counterpart tests for the store-file failure branches.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-16 21:16:49 +02:00
StefanandClaude Sonnet 5 99bf5337f8 fix: invoice/reminder PDF preview 404s and their unlogged root causes
An invoice's finalisation-time PDF render/store (fds__setInvoiceFile) can
fail silently; the row stays finalised with [file] = NULL and inv/rdoc
(the invoice list's preview icon) 404'd forever with no trace in any log.

- inv/rdoc and rem/idoc now regenerate and persist a missing document on
  read, mirroring the legacy VB "idoc" fallback (Fuchs_Database ADR 0025),
  gated to finalised + non-external invoices only — a draft render must
  never be persisted, since fds__setInvoiceFile/fds__setReminderFile only
  write WHERE [file] IS NULL and finalisation would then silently no-op.
- StoreInvoiceDocumentFileAsync/StoreReminderDocumentFileAsync now log
  every distinct failure (render threw, render produced 0 bytes, DB write
  threw, DB write matched 0 rows) instead of collapsing them into a single
  silent empty-array return. The 0-rows case needed switching from
  setSQLValue_async (reports success once the command completes, whatever
  the row count) to setSQLValue_async_result, whose AffectedRows makes a
  no-op UPDATE distinguishable from an actual write for the first time.
- HandleRequestIdoc/HandleReminderIdoc no longer treat
  StoreInvoiceDocumentFileAsync's Array.Empty<byte>() failure signal as
  success (a bare `ct == null` check never caught it), which would have
  served a 0-byte "PDF".
- rdoc/HandleRequestIdoc reject a missing 'id' form field instead of doing
  wasted work with an empty id.
- _BackupAndClearInvoiceFile.sql is annotated as a manual-only maintenance
  utility, never called from app code, so it's clear why running it leaves
  no application log trail.
- Client-side: $inv.pdfPreviewTimeout was 120 (ms) instead of 120000 — the
  preview dialog was timing out client-side after 120ms on every call.
- Fixed a genuine process-wide test flake: PdfPipelineTests and
  ERechnungMapperTests both install PdfSharp's global font resolver, and
  whichever ran first would win, making the other's tests fail
  deterministically once eRechnungLib's SystemFontResolver got there
  first. FuchsPdf.EnsureFontResolver is now also race-safe under
  concurrent first use (a check-then-set without a lock). Also removed
  the InvoiceService.PublishAsync failure logging added mid-session after
  code review found it duplicated every call site's own, more detailed
  logging against ADR 0024's single-write-path rule.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-16 21:07:24 +02:00
StefanandClaude Sonnet 5 b5613bee0d docs: sync CA1416 build note across CLAUDE.md/CODEX.md/copilot-instructions.md
CLAUDE.md and CODEX.md already documented that CA1416 is suppressed
solution-wide via the root .editorconfig; copilot-instructions.md was
missing that note, breaking the three-file sync rule the header of each
file requires. Also adds the .editorconfig itself, which the note
references and which was untracked.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-16 21:07:07 +02:00
StefanandClaude Sonnet 5 bcf1f2dbb6 feat: migrate DB error logging onto the generic OCORE MultiLogger SQL sink
Playwright Tests / test (push) Canceled after 0s
Retires Fuchs_intranet.debug_log() and every direct EXECUTE [dbo].[fds__admin_logdebug]
call in favor of a single path: ILogger. A new SQL log sink (OCORE.MultiLogger.SqlLoggerProvider,
gated by Fuchs:Logging:SqlSink:Enabled, default on) mirrors every _logger.LogWarning/-Error/
-Critical call into [dbo].[fds__debuglog] via a new fds__admin_logdebug_ext proc, at Warning+.

- Program.cs registers the sink and RequestLogScopeMiddleware (request id/path/authenticated
  user, pushed after UseAuthentication) so every log line in a request — including ones written
  deep inside OCORE's SQL helpers — is attributed automatically.
- FIS_SQLOptions, IntranetController.Do's catch-all, PdfPreviewAsync, and the banking-upload
  handlers now log once, with an explicit EventId preserving the exact CodeReference text the
  retired debug_log() calls used to write, so existing dashboards/queries over fds__debuglog
  keep working unchanged.
- Fuchs_DataService/FdsMfr.cs's FdsSqlOptions had the identical double-write hazard (it's hosted
  in the same process per ADR 0010) — the original 2026-09-16 log review's mfr__getSchema/
  fds__getUpdateableRequests deadlock rows came from exactly this class. Fixed the same way.
- fds__debuglog gains LogLevel/Machine/Category/Account/AuthUser/IsDebug columns; the legacy
  fds__admin_logdebug proc is left in place unused, not dropped.
- FdsDebug.DebugLog (Fuchs_DataService's other ~9 call sites) deliberately left on its direct
  DB-write path — no ILogger call sits alongside it there, so no duplication risk today.

See ADR 0024 (supersedes 0023) and Docs/Concepts/error-logging-pipeline.md.

New tests: ErrorLoggingTests (FIS_SQLOptions), FdsSqlOptionsTests, RequestLogScopeMiddlewareTests.
694/694 pass (the 4 PdfPipelineTests failures seen under full-suite parallel runs are a
pre-existing, unrelated PdfSharp global-font-resolver test-isolation flake — they pass both
individually and in most full-suite runs).

Bumps the OCORE submodule to 3137b56 (SQL sink hardening: redaction matches short fragments
as whole identifier segments, per-entry-tolerant serialization, lazy dataSerialized, opt-in
SqlLoggerOptions.IncludeCategory, LogEntry.Category/AuthUser capture).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-16 17:20:06 +02:00
Stefan a1e6002c3b chore: Update OCORE and OCORE_web submodules to latest commits
Playwright Tests / test (push) Canceled after 0s
2026-09-16 16:35:35 +02:00
Stefan fce83c528f feat: Enhance error logging for PDF preview failures and SQL timeouts
- Implemented a new method `ShapeTableNames` to gather entity and navigation table names, ensuring no blanks or duplicates are included.
- Updated `FdsMfrClient` to handle cases where schema queries return no tables, logging appropriate warnings and preventing misleading errors.
- Introduced `PdfPreviewAsync` in `IntranetController` to manage PDF preview rendering, capturing detailed logs for failures and notifying users with readable error messages.
- Added comprehensive unit tests for error logging and table name shaping to ensure robustness and correctness.
- Documented decision to persist user action failures to the `fds__admin_logdebug` table, enhancing support diagnostics.
2026-09-16 16:35:04 +02:00
Stefan 38e0d55b09 Improve refreshRow to handle overlapping AJAX responses
Playwright Tests / test (push) Canceled after 0s
Refactored refreshRow in fis.usr.de.js to look up the row node by id inside the AJAX response handler, ensuring correct replacement even with overlapping calls (e.g., rapid checkbox toggles). This prevents errors when a previous call detaches the node before a later response arrives. Enhanced fallback to full reload for robustness. Updated fis.usr.de.min.js accordingly. Reformatted tools.js with no content changes.
2026-09-16 09:45:09 +02:00
Stefan 2f39440b05 chore: Update OCORE submodule to latest commit
Playwright Tests / test (push) Canceled after 0s
2026-09-16 09:42:46 +02:00
Stefan b26c24ef98 feat: Enhance user group management and transaction handling in stored procedures
- Improved user group deletion logic to accurately report success or failure.
- Updated user group renaming checks to prevent unintended inserts or updates.
- Added documentation on transaction blocking risks during full resets in stored procedures.
- Removed unnecessary login scripts from database project.
2026-09-16 09:42:32 +02:00
Stefan 8bcec409c5 feat: Add Admin Groups Management Module
- Introduced a new module for managing user groups (`usrgrp`) in the administration interface.
- Implemented CRUD operations for user groups: list, create, rename, and delete.
- Added authorization checks to ensure only users with appropriate permissions can manage groups.
- Created corresponding frontend JavaScript and SCSS files for the new module.
- Updated documentation to reflect the new group management functionality and its authorization model.
- Added SQL stored procedures for group management operations.
- Established a new login script for database access, ensuring proper security practices.
2026-09-16 09:35:31 +02:00
Stefan 1a5ac73f3c Update Mailer configuration in appsettings for development and production
Playwright Tests / test (push) Canceled after 0s
2026-09-15 23:59:48 +02:00
StefanandClaude Sonnet 5 c757533503 Sync OCORE and OCORE_web submodule pointers
Playwright Tests / test (push) Canceled after 0s
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-15 23:54:04 +02:00
StefanandClaude Sonnet 5 107b7f54f1 Verify mailer credentials against ProcessWeb API before showing E-Mail-Dienst as healthy
Playwright Tests / test (push) Canceled after 0s
The Admin status pill previously turned green whenever the mailer was
enabled in config, without proving the account/token actually work.
Add IComService.VerifyCredentialsAsync (GET /mailer/fc?fn=verify, also
passing serverid when configured so the API confirms this server
belongs to the account) and wire it into a new "email" system-status
probe, so the pill only goes green once the Mailer API itself confirms
the credentials.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-15 23:51:32 +02:00
StefanandCopilot f36f65789e Sync OCORE submodule pointer and ignore local Claude Code settings
Playwright Tests / test (push) Canceled after 0s
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-09-15 18:26:19 +02:00
Stefan 0ab08fd50b -
Playwright Tests / test (push) Canceled after 0s
2026-09-15 18:24:21 +02:00
Stefan 263946d0fc Update NuGet packages, publish profiles, and config
- Bump Microsoft.NET.Test.Sdk, xunit.v3, MailKit, MimeKit, SixLabors.ImageSharp, Microsoft.Data.SqlClient, and Microsoft.Identity.Client.Extensions.Msal to latest patch versions
- Publish ZIP filename now includes environment name (Development/Production) in Fuchs.csproj
- Add FolderProfile-Dev.pubxml (Development) and FolderProfile.pubxml (Production) with EnvironmentName property
- Fix connection string keys in appsettings.Development.json to match production; set IsTestDeployment=true for development
- Set production DB and Azure Storage container names in appsettings.json; IsTestDeployment=false for production
- Mark OCORE, OCORE_web, and eRechnungLib submodules as dirty (local changes)
2026-09-15 18:24:17 +02:00
StefanandClaude Sonnet 5 6cc79122b3 Improve Set-Struktur editor: show parent set inline, clamp long descriptions
Playwright Tests / test (push) Canceled after 0s
Removes the per-row indentation (span.ind) in favor of a small "belongs to
set" tag shown above each row's title, so membership is visible without
tracing indentation depth even while sets are expanded. Description text
is now clamped to 2 lines with ellipsis by default and expands to a
scrollable ~12-line view on click.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-14 16:17:53 +02:00
StefanandClaude Sonnet 5 4386a6336a Let admins create and delete users in Benutzerverwaltung, modernize list
Playwright Tests / test (push) Canceled after 0s
Wire up two previously-unwired stored procedures for the usr module:
- fis_admin_createUserAccount gained the missing @authaccount +
  intranet_admin auth gate, a duplicate-email check, and now returns
  the (possibly auto-generated) password so it can be emailed to the
  new user, mirroring fis_admin_resetUserPassword's pattern.
- fis_admin_delUserAccount gained the same missing auth gate plus a
  guard against an admin deleting their own account.

New /do/usr/create and /do/usr/delete endpoints in
IntranetController.AdminUsers.cs, a "Neuer Benutzer" button and a
"Benutzer löschen" ribbon action in fis.usr.js, and unit tests for the
new welcome-email content (BuildWelcomeEmail).

Also restyle the user list (fis.usr.scss) from a plain bordered table
to a card-style row grid with avatar-initial badges and status pills,
matching the Admin dashboard's visual language.

Documented as ADR 0020 and in the admin-user-management concept doc.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-14 14:51:59 +02:00
StefanandClaude Sonnet 5 38145a917e Fix login accepting wrong credentials (empty user signed in anyway)
fis_admin_getUserAccount always inserted a result row, even when no
matching user was found, so fis_admin_authenticate never truly
returned "no result" on wrong credentials — the login handler then
signed the caller in with an empty useraccount_id. The authenticated
shell rendered (IsAuthenticated only checks the claim exists), while
every data endpoint 401'd (FuchsUserIdentity.IsAuthenticated also
checks the id is non-empty), producing an empty portal.

Also treat an empty useraccount_id in HandleLogin as a failed login
(defense in depth) and fix a column-name mismatch (auth vs
authorization) that was silently zeroing the authorization level.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-14 14:51:44 +02:00
StefanandClaude Sonnet 5 c18e34a3f1 Update OCORE/OCORE_web submodules, align Microsoft.Data.SqlClient to 7.0.3
Pull latest origin/main for OCORE and OCORE_web. OCORE now depends on
Microsoft.Data.SqlClient 7.0.3, so bump Fuchs and Fuchs_DataService's
own pinned reference to match and avoid an NU1605 downgrade error.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-14 14:51:37 +02:00
Stefan e69a146408 Implement feature X to enhance user experience and optimize performance
Playwright Tests / test (push) Canceled after 0s
2026-09-11 08:29:55 +02:00
StefanandClaude Sonnet 5 52b2811b43 Render invoice-editor <img> elements on their own line
Playwright Tests / test (push) Canceled after 0s
<img> is a nominally inline HTML element, but TinyMCE content places images
between paragraphs of text; without a forced line break before/after, an
image could render packed against adjacent text in the PDF. Insert a line
break ahead of the image (unless one is already there) and always after it.
Also fixes the img handler's early-return path to still apply the inline
size cap (MaxInlineImageWidth/Height) when only one of width/height was
resized, matching the aspect-ratio-locked branch.

Fuchs/wwwroot/web/tools.js: cosmetic gulp rebuild diff (leading blank line),
no behavior change.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-10 22:20:44 +02:00
StefanandClaude Fable 5.1 5fa7a2b50f Key the set-price menu modes on the set instead of the service-request block (ADR 0019)
"Set mit Preis" / "Nur Set mit Preis" collapsed every block into an inserted
"Gesamtumfang pauschal" row, nulled/removed all lines and cleared the user-defined
set structure. Both modes now convert every still-unconverted top-level set:
the set line takes the sum of its subtree, its members render without a price
(setprice) or are removed from itm + items (setonly); lines outside a set stay
individually priced and several sets stay separate lines. Nested sets are priced
once via their top-level set. One conversion primitive (ConvertSetHeader) now
backs all three triggers; item.setprice is a no-op on an already-converted set
(a repeated delta used to reset the set price to 0). The header's price_net is
mirrored too (flat PDF path unit price), and Build treats a null total_net as
"no price" like the flat path. The editor checks for a set before offering the
modes and the menu entry stays available for sets added later.

ADR 0009 is superseded by 0019; INVOICE_SET_PRICING.md rewritten accordingly.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-10 22:19:01 +02:00
Stefan 22d2ef9c1a Refactor code structure for improved readability and maintainability
Playwright Tests / test (push) Canceled after 0s
2026-09-10 21:17:35 +02:00
StefanandClaude Sonnet 5 f407341f5f Parallelize invoice image fetch, add telemetry, generalize HtmlConverter
- InvoiceService.ResolveInlineImagesAsync now fetches an invoice's
  distinct whitelisted images concurrently (SemaphoreSlim-capped at 4)
  instead of one at a time. It ran inline on every invoice render,
  including repeated draft-preview renders, so N sequential mfr-portal
  round-trips could otherwise add N x latency to a user-facing request.
- Add fuchs.invoice.inlineimages.fetches (counter, tagged by outcome:
  success/http_error/bad_content_type/oversized/exception) and
  fuchs.invoice.inlineimages.resolve.duration (histogram) to
  FuchsTelemetry, matching the project's observability convention for
  outbound-HTTP operations.
- MigraDocExtensions.HtmlConverter's pre-resolved-image parameter is
  renamed inlineImages -> resolvedImages with generic docs: the
  converter fetches nothing and knows nothing about invoices — which
  images to resolve, and any whitelist/network policy, is entirely the
  caller's concern (InvoiceService, ADR 0018 today; any future caller
  tomorrow).
- Add tests asserting fetches actually overlap (not just faster) and
  that the new counter/histogram are recorded.
- Update ADR 0018 to describe the concurrent fetch, telemetry, and the
  generalized converter parameter.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-10 21:17:09 +02:00
StefanandClaude Sonnet 5 406c6f2860 Fix invoice-image code-review findings: aspect ratio, whitelist config, stale refs
- MigraDocExtensions: fix a bug where capping an oversized explicit
  width+height image re-derived the other dimension from the image's
  native pixel ratio instead of scaling both sides uniformly, silently
  distorting a deliberately non-native-ratio (e.g. stretched) image.
- InvoiceService: derive the mfr-portal image whitelist prefix from the
  existing Fds:MFR_host config setting (via MFRClientConfig.RestRoot)
  instead of a second hardcoded literal, so it can't drift from the
  actual configured host; parse <img> sources with HtmlAgilityPack
  (already used by the PDF's own HTML converter) instead of a regex, so
  a decoy attribute like data-src can't be picked up in place of the
  real src; dedup fetch URLs with a HashSet instead of O(n^2) List
  scanning; reuse FuchsPdf.MigraDocFilenameFromByteArray instead of
  re-spelling the "base64:" prefix.
- Fix a stale log message and comment left over from the
  GenerateInvoicePdf -> GenerateInvoicePdfAsync rename, and update
  INVOICE_LIFECYCLE.md's references to the old sync method name.
- Add tests for the config-derived whitelist, a decoy-attribute src,
  and the 20-image-per-invoice fetch cap boundary.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-10 21:03:09 +02:00
StefanandClaude Sonnet 5 c182d4fd69 Add row context menus, fix glyphicon copy corruption, combine CC email editing
- Fuchs/gulpfile.js: pass encoding:false to gulp.src() in the copy task —
  vinyl-fs otherwise UTF-8-decodes binary files by default, which was
  silently corrupting the glyphicons webfont (and any other binary asset
  copyconfig.json matches) on every `gulp copy`/`gulp all` run.
- Invoice/reminder editor: replace the row's flat action icons (edit/
  delete/insert/set-price) with a single context button opening a
  dropdown menu, reusing the existing $ocms.menu toolbar mechanism.
- Fix "-1" leaking into the Pos. column for Text/Title rows: $inv.rrw's
  `dta.position || dta.SortOrder` fallback treated the numbering's
  intentional empty string as unset and fell back to SortOrder (-1 for
  such rows); now only a genuinely uncomputed position falls back.
- Zusatzempfänger (CC) is now edited together with the main invoice
  email in one combined dialog ($inv.emailFrm/$inv.eEmail), wired into
  all three entry points (create, continue, storno) instead of a
  separate hover-revealed row with its own edit target.
- Add tooltips to every row/header edit control, including several that
  had none before (header field pencils, the request/section title
  edit button), phrased as object+verb ($rct.frmTT).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-10 19:38:03 +02:00
StefanandClaude Sonnet 5 071982caec Render invoice-editor images (mfr portal only) into the PDF
The invoice line editor already shows <img> pictures pulled from the mfr
portal, but the PDF pipeline's HTML-to-MigraDoc converter had no <img>
handler, so they never appeared on the actual invoice.

InvoiceService now fetches only https://portal.mobilefieldreport.com/mfr/*
image sources before rendering (size/type/count capped, failures logged and
skipped) and hands the resolved images down through ApplyInvoice into the
HTML converter, which renders them inline at their original HTML position
and size (px converted to cm, capped against layout overflow). See ADR 0018.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-10 19:36:31 +02:00
284 changed files with 14174 additions and 2676 deletions
+7
View File
@@ -0,0 +1,7 @@
root = true
[*.cs]
# CA1416: Validate platform compatibility. This app targets Windows only (System.Drawing,
# registry, etc. usage throughout Fuchs/OCORE); the platform-compat analyzer's warnings here
# are expected noise, not signal. See CLAUDE.md "Build & Test" for the pre-existing-warnings note.
dotnet_diagnostic.CA1416.severity = none
+8 -3
View File
@@ -13,8 +13,10 @@
- **Fuchs Intranet** is an ASP.NET Core (.NET 10) web application — the intranet IS the entire website, served from `/`. - **Fuchs Intranet** is an ASP.NET Core (.NET 10) web application — the intranet IS the entire website, served from `/`.
- Routes: `/{fn?}/{id?}/{code?}` → `IntranetController.Index`; `/do/{fn?}/{id?}/{code?}` → `IntranetController.Do`. - Routes: `/{fn?}/{id?}/{code?}` → `IntranetController.Index`; `/do/{fn?}/{id?}/{code?}` → `IntranetController.Do`.
- Build app: `dotnet build Fuchs/Fuchs.csproj -c Debug`. Build all: `dotnet build Fuchs_Intranet.slnx -c Debug`. - Build app: `dotnet build Fuchs/Fuchs.csproj -c Debug`. Build all: `dotnet build Fuchs_Intranet.slnx -c Debug`.
- Build the database project whenever anything under `Fuchs_Database/` changes: `msbuild Fuchs_Database/FuchsDatabase.sqlproj -p:Configuration=Debug` (MSBuild from the Visual Studio install, e.g. `"C:\Program Files\Microsoft Visual Studio\18\Community\MSBuild\Current\Bin\MSBuild.exe"`; `dotnet build` cannot build the legacy SSDT project). T-SQL warnings are build errors there (ADR 0036): an unresolved or ambiguous column (SQL71502) fails the build, and a red database build is a failure like a red C# build. The working-tree SQL is **not** on any database until it is published, so this build is the only check the SQL gets before review.
- Frontend assets are source-built: run the gulp tasks in `Fuchs/` (`npx gulp min`, or `npx gulp all` when copied/static assets also need refreshing) whenever JS or SCSS/CSS sources change. The generated files under `Fuchs/wwwroot/web/` are what the app serves. - Frontend assets are source-built: run the gulp tasks in `Fuchs/` (`npx gulp min`, or `npx gulp all` when copied/static assets also need refreshing) whenever JS or SCSS/CSS sources change. The generated files under `Fuchs/wwwroot/web/` are what the app serves.
- Test: `dotnet test Fuchs.Tests/Fuchs.Tests.csproj -c Debug`. - Test: `dotnet test --project Fuchs.Tests/Fuchs.Tests.csproj -c Debug` (the root `global.json` opts `dotnet test` into the Microsoft.Testing.Platform runner that xunit.v3 needs on the .NET 10 SDK; the positional-path form no longer works).
- Always build **and** run the test suite before committing. CA1416 (platform-compatibility) is suppressed solution-wide via the root `.editorconfig` — the app is Windows-only. The build may still emit other pre-existing analyzer warnings; only treat `: error` lines as failures.
- Submodules include the OCORE projects and `eRechnungLib` (ZUGFeRD/Factur-X + XRechnung generation) — invoices are moving to eRechnung output. - Submodules include the OCORE projects and `eRechnungLib` (ZUGFeRD/Factur-X + XRechnung generation) — invoices are moving to eRechnung output.
- Project structure (relative to `Fuchs/`): - Project structure (relative to `Fuchs/`):
- `Controllers/` — `IntranetController` partials (no area) - `Controllers/` — `IntranetController` partials (no area)
@@ -48,7 +50,7 @@
## Services & Dependency Injection ## Services & Dependency Injection
- Business logic lives in **DI-registered services** under `Fuchs/Services/` behind interfaces; inject them into `IntranetController` (constructor injection). Do **not** reintroduce static God-classes or pass the whole controller into helpers. - Business logic lives in **DI-registered services** under `Fuchs/Services/` behind interfaces; inject them into `IntranetController` (constructor injection). Do **not** reintroduce static God-classes or pass the whole controller into helpers.
- `IComService` (email/SMS via ProcessWeb Mailer API, attachments sent inline as base64; honors the `Fuchs:Email:OverrideRecipient` dev safety net — see Configuration), `IPdfService` (MigraDoc render), `IInvoiceService`, `IReminderService`, `IReportService` (SQL report engine via `FuchsVisualization`), `IWidgetService`, `IBankingService`, `IMfrClientFactory`, `ISystemStatusService` (Admin module diagnostics: config snapshot + DB/Key Vault/blob/MFR connectivity probes + test-email; restricted to `fds_sys` > 4). - `IComService` (email/SMS via ProcessWeb Mailer API — contract at **https://processweb.de/api/mailer/fc**, read it and `Fuchs/Docs/Concepts/processweb-mailer-interface.md` before changing it. `ProcessWebComService` is a thin adapter over the OCORE_web `IPWMailerClientService` (`OCORE_web/comm/pwmailer/`, owns the HTTP contract; ADR 0029) with `FuchsMailerClient` overriding `ValidateMessage` for attachment limits; a send only counts as successful when the receipt's `attachmentsProcessed` matches the number sent (ADR 0028); honors the `Fuchs:Email:OverrideRecipient` dev safety net — see Configuration), `IPdfService` (MigraDoc render), `IInvoiceService`, `IReminderService`, `IReportService` (SQL report engine via `FuchsVisualization`), `IWidgetService`, `IBankingService`, `IMfrClientFactory`, `ISystemStatusService` (Admin module diagnostics: config snapshot + DB/Key Vault/blob/MFR connectivity probes + test-email; restricted to `fds_sys` > 4).
- Lifetimes: stateless services (`IPdfService`, `IBankingService`, `IMfrClientFactory`) are singletons; request-scoped DB services (`IInvoiceService`, `IReminderService`, `IReportService`, `IWidgetService`, `IComService`, `ISystemStatusService`) are scoped. Register in `Program.cs`. - Lifetimes: stateless services (`IPdfService`, `IBankingService`, `IMfrClientFactory`) are singletons; request-scoped DB services (`IInvoiceService`, `IReminderService`, `IReportService`, `IWidgetService`, `IComService`, `ISystemStatusService`) are scoped. Register in `Program.cs`.
- `FdsInvoiceData` / `FdsReminderData` are **pure data holders** (parse + properties). Loading, persistence and PDF generation belong in the services — never `Task.Run(...).Wait()` sync-over-async. - `FdsInvoiceData` / `FdsReminderData` are **pure data holders** (parse + properties). Loading, persistence and PDF generation belong in the services — never `Task.Run(...).Wait()` sync-over-async.
- Data access stays SQL-first via OCORE helpers (`getSQLDataSet_async`, `setSQLValue_async`) + stored procedures; no EF Core. - Data access stays SQL-first via OCORE helpers (`getSQLDataSet_async`, `setSQLValue_async`) + stored procedures; no EF Core.
@@ -60,6 +62,8 @@
## Database ## Database
- The SQL schema source of truth is the **`Fuchs_Database`** SSDT project. The backend is SQL-first (stored procedures, table types like `fds__tt__bankingtransactions`, functions via OCORE helpers — no EF Core). - The SQL schema source of truth is the **`Fuchs_Database`** SSDT project. The backend is SQL-first (stored procedures, table types like `fds__tt__bankingtransactions`, functions via OCORE helpers — no EF Core).
- When you change a stored proc name/params or a table type, update **both** the SSDT project and the calling C# in the same change. Verify every `[dbo].[…]` the backend calls actually exists in `Fuchs_Database`. - When you change a stored proc name/params or a table type, update **both** the SSDT project and the calling C# in the same change. Verify every `[dbo].[…]` the backend calls actually exists in `Fuchs_Database`.
- Objects the SQL references must be **in the project** (a referenced-but-missing function only shows up as an SSDT build error now; the `ocms_*` helpers are vendored copies from the OCMSsharp `ocms_mt_database` project). `Security/Logins.sql` holds placeholder logins only so the users bind — real passwords live in Key Vault.
- `Fuchs.Tests/SqlContractTests` checks every `[dbo].[…]` named in C# against the SSDT files (exists, `EXECUTE` binds, function arg counts; ADR 0036) — a proc signature change that breaks a caller fails the test suite.
## Bank statement parsing (MT940 + CAMT) ## Bank statement parsing (MT940 + CAMT)
- Two parsers feed the same banking pipeline: the external `MT940Parser` (SWIFT text) and the in-repo **`CAMTParser`** project (ISO 20022 camt.052/053/054 XML). - Two parsers feed the same banking pipeline: the external `MT940Parser` (SWIFT text) and the in-repo **`CAMTParser`** project (ISO 20022 camt.052/053/054 XML).
@@ -68,8 +72,9 @@
## Observability ## Observability
- Use **OpenTelemetry**. The app's instrumentation is centralised in `Fuchs/Observability/FuchsTelemetry.cs` (one `ActivitySource` + one `Meter`). - Use **OpenTelemetry**. The app's instrumentation is centralised in `Fuchs/Observability/FuchsTelemetry.cs` (one `ActivitySource` + one `Meter`).
- When adding a meaningful operation: start an activity (`FuchsTelemetry.StartActivity(...)`), record the relevant counter/histogram, and log entry/result/timing/errors via the injected `ILogger<T>`. Prefer structured logging (named placeholders), never string interpolation in log messages. - When adding a meaningful operation: start an activity (`FuchsTelemetry.StartActivity(...)`), record the relevant counter/histogram, and log entry/result/timing/errors via the injected `ILogger<T>`. Prefer structured logging (named placeholders), never string interpolation in log messages — and never `{@Foo}`: plain `Microsoft.Extensions.Logging` (unlike Serilog) does not strip the `@`, it becomes a literal part of the key.
- Tracing/metrics are always collected; OTLP export is opt-in via `Fuchs:Telemetry:OtlpEndpoint`. Don't add exporters that fail hard when no collector is present. - Tracing/metrics are always collected; OTLP export is opt-in via `Fuchs:Telemetry:OtlpEndpoint`. Don't add exporters that fail hard when no collector is present.
- **DB log sink (ADR 0024):** every `_logger.LogWarning`/`-Error`/`-Critical` call additionally lands in `[dbo].[fds__debuglog]` via the OCORE MultiLogger SQL sink (`fds__admin_logdebug_ext`), gated by `Fuchs:Logging:SqlSink:Enabled` (default on). `RequestLogScopeMiddleware` (`Fuchs/Logging/`) attributes every log line in a request to a request id/path/authenticated user automatically. Give a log call an explicit `EventId` (its `Name` becomes the row's `CodeReference`) when the call site should be individually greppable — see `FIS_SQLOptions`, `IntranetController.Do`'s catch-all, `PdfPreviewAsync`, and the banking-upload handlers for the pattern. Do not also call the retired `Fuchs_intranet.debug_log` — one `ILogger` call is the single write path; a second explicit DB write duplicates the row.
## Testing ## Testing
- xUnit in `Fuchs.Tests`. Testing must be **extensive**, not superficial: - xUnit in `Fuchs.Tests`. Testing must be **extensive**, not superficial:
+3
View File
@@ -3,6 +3,9 @@
*.user *.user
*.suo *.suo
# Claude Code local settings (machine-specific, not shared)
.claude/
# Build Output # Build Output
bin/ bin/
obj/ obj/
+24 -1
View File
@@ -2,7 +2,7 @@
"version": "0.2.0", "version": "0.2.0",
"configurations": [ "configurations": [
{ {
"name": "Fuchs: Debug ASP.NET Core", "name": "Fuchs: Debug (Debug config, Development env)",
"type": "coreclr", "type": "coreclr",
"request": "launch", "request": "launch",
"preLaunchTask": "build Fuchs", "preLaunchTask": "build Fuchs",
@@ -23,6 +23,29 @@
"sourceFileMap": { "sourceFileMap": {
"/Views": "${workspaceFolder}/Fuchs/Views" "/Views": "${workspaceFolder}/Fuchs/Views"
} }
},
{
"name": "Fuchs: Launch (Release config, Production env)",
"type": "coreclr",
"request": "launch",
"preLaunchTask": "build Fuchs Release",
"program": "${workspaceFolder}/Fuchs/bin/Release/net10.0/Fuchs.dll",
"args": [],
"cwd": "${workspaceFolder}/Fuchs",
"stopAtEntry": false,
"justMyCode": true,
"serverReadyAction": {
"action": "openExternally",
"pattern": "\\bNow listening on:\\s+(https?://\\S+)",
"uriFormat": "%s"
},
"env": {
"ASPNETCORE_ENVIRONMENT": "Production",
"ASPNETCORE_URLS": "https://localhost:63661;http://localhost:63662"
},
"sourceFileMap": {
"/Views": "${workspaceFolder}/Fuchs/Views"
}
} }
] ]
} }
+15
View File
@@ -19,6 +19,21 @@
"isDefault": true "isDefault": true
} }
}, },
{
"label": "build Fuchs Release",
"type": "process",
"command": "dotnet",
"args": [
"build",
"${workspaceFolder}/Fuchs/Fuchs.csproj",
"--configuration",
"Release",
"/p:RestoreIgnoreFailedSources=true",
"/p:RestoreConfigFile=${workspaceFolder}/NuGet.config"
],
"problemMatcher": "$msCompile",
"group": "build"
},
{ {
"label": "gulp: all", "label": "gulp: all",
"type": "shell", "type": "shell",
+8 -4
View File
@@ -15,9 +15,10 @@
## Build & Test (workflow) ## Build & Test (workflow)
- Build app: `dotnet build Fuchs/Fuchs.csproj -c Debug`. Build all: `dotnet build Fuchs_Intranet.slnx -c Debug`. - Build app: `dotnet build Fuchs/Fuchs.csproj -c Debug`. Build all: `dotnet build Fuchs_Intranet.slnx -c Debug`.
- Build the database project whenever anything under `Fuchs_Database/` changes: `msbuild Fuchs_Database/FuchsDatabase.sqlproj -p:Configuration=Debug` (MSBuild from the Visual Studio install, e.g. `"C:\Program Files\Microsoft Visual Studio\18\Community\MSBuild\Current\Bin\MSBuild.exe"`; `dotnet build` cannot build the legacy SSDT project). T-SQL warnings are build errors there (ADR 0036): an unresolved or ambiguous column (SQL71502) fails the build, and a red database build is a failure like a red C# build. The working-tree SQL is **not** on any database until it is published, so this build is the only check the SQL gets before review.
- Frontend assets are source-built: run the gulp tasks in `Fuchs/` (`npx gulp min`, or `npx gulp all` when copied/static assets also need refreshing) whenever JS or SCSS/CSS sources change. The generated files under `Fuchs/wwwroot/web/` are what the app serves. - Frontend assets are source-built: run the gulp tasks in `Fuchs/` (`npx gulp min`, or `npx gulp all` when copied/static assets also need refreshing) whenever JS or SCSS/CSS sources change. The generated files under `Fuchs/wwwroot/web/` are what the app serves.
- Test: `dotnet test Fuchs.Tests/Fuchs.Tests.csproj -c Debug`. - Test: `dotnet test --project Fuchs.Tests/Fuchs.Tests.csproj -c Debug` (the root `global.json` opts `dotnet test` into the Microsoft.Testing.Platform runner that xunit.v3 needs on the .NET 10 SDK; the positional-path form no longer works).
- Always build **and** run the test suite before committing. The build emits many pre-existing analyzer/platform warnings (CA1416 etc.) — those are expected; only treat `: error` lines as failures. - Always build **and** run the test suite before committing. CA1416 (platform-compatibility) is suppressed solution-wide via the root `.editorconfig` — the app is Windows-only. The build may still emit other pre-existing analyzer warnings; only treat `: error` lines as failures.
- Commit only when asked. Co-author trailer: `Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>`. - Commit only when asked. Co-author trailer: `Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>`.
- The working tree may contain an untracked `Fuchs_Database/` SQL project — it is **not** part of app changes; never `git add -A` it into an unrelated commit. Stage explicit paths. - The working tree may contain an untracked `Fuchs_Database/` SQL project — it is **not** part of app changes; never `git add -A` it into an unrelated commit. Stage explicit paths.
@@ -38,7 +39,7 @@
## Services & Dependency Injection ## Services & Dependency Injection
- Business logic lives in **DI-registered services** under `Fuchs/Services/` behind interfaces, injected into `IntranetController`. Do not reintroduce static God-classes or pass the controller into helpers. - Business logic lives in **DI-registered services** under `Fuchs/Services/` behind interfaces, injected into `IntranetController`. Do not reintroduce static God-classes or pass the controller into helpers.
- Services: `IComService` (ProcessWeb Mailer API; honors the `Fuchs:Email:OverrideRecipient` dev safety net — see Configuration), `IPdfService`, `IInvoiceService`, `IReminderService`, `IReportService`, `IWidgetService`, `IBankingService`, `IMfrClientFactory`, `ISystemStatusService` (Admin module diagnostics: config snapshot + DB/Key Vault/blob/MFR connectivity probes + test-email; restricted to `fds_sys` > 4). Stateless ones are singletons; DB/request-scoped ones are scoped (see `Program.cs`). - Services: `IComService` (ProcessWeb Mailer API — contract at **https://processweb.de/api/mailer/fc**, read it and `Fuchs/Docs/Concepts/processweb-mailer-interface.md` before changing it. `ProcessWebComService` is a thin adapter over the OCORE_web `IPWMailerClientService` (`OCORE_web/comm/pwmailer/`, owns the HTTP contract; ADR 0029) with `FuchsMailerClient` overriding `ValidateMessage` for attachment limits; a send only counts as successful when the receipt's `attachmentsProcessed` matches the number sent (ADR 0028); honors the `Fuchs:Email:OverrideRecipient` dev safety net — see Configuration), `IPdfService`, `IInvoiceService`, `IReminderService`, `IReportService`, `IWidgetService`, `IBankingService`, `IMfrClientFactory`, `ISystemStatusService` (Admin module diagnostics: config snapshot + DB/Key Vault/blob/MFR connectivity probes + test-email; restricted to `fds_sys` > 4). Stateless ones are singletons; DB/request-scoped ones are scoped (see `Program.cs`).
- `FdsInvoiceData` / `FdsReminderData` are **pure data holders**; load/persist/render belongs in services. No `Task.Run(...).Wait()` sync-over-async. - `FdsInvoiceData` / `FdsReminderData` are **pure data holders**; load/persist/render belongs in services. No `Task.Run(...).Wait()` sync-over-async.
- Data access is SQL-first via OCORE helpers + stored procedures (no EF Core). - Data access is SQL-first via OCORE helpers + stored procedures (no EF Core).
@@ -49,6 +50,8 @@
## Database ## Database
- Schema source of truth: **`Fuchs_Database`** SSDT project. SQL-first backend (stored procs, table types e.g. `fds__tt__bankingtransactions`, functions via OCORE — no EF Core). - Schema source of truth: **`Fuchs_Database`** SSDT project. SQL-first backend (stored procs, table types e.g. `fds__tt__bankingtransactions`, functions via OCORE — no EF Core).
- Changing a proc signature or table type → update the SSDT project **and** the calling C# together; verify every `[dbo].[…]` the backend calls exists in `Fuchs_Database`. - Changing a proc signature or table type → update the SSDT project **and** the calling C# together; verify every `[dbo].[…]` the backend calls exists in `Fuchs_Database`.
- Objects the SQL references must be **in the project** (a referenced-but-missing function only shows up as an SSDT build error now; the `ocms_*` helpers are vendored copies from the OCMSsharp `ocms_mt_database` project). `Security/Logins.sql` holds placeholder logins only so the users bind — real passwords live in Key Vault.
- `Fuchs.Tests/SqlContractTests` checks every `[dbo].[…]` named in C# against the SSDT files (exists, `EXECUTE` binds, function arg counts; ADR 0036) — a proc signature change that breaks a caller fails the test suite.
## Bank statement parsing (MT940 + CAMT) ## Bank statement parsing (MT940 + CAMT)
- `MT940Parser` (external, SWIFT text) and **`CAMTParser`** (in-repo, ISO 20022 camt.052/053/054 XML) feed the same pipeline. - `MT940Parser` (external, SWIFT text) and **`CAMTParser`** (in-repo, ISO 20022 camt.052/053/054 XML) feed the same pipeline.
@@ -56,8 +59,9 @@
## Observability ## Observability
- OpenTelemetry. Instrumentation is centralised in `Fuchs/Observability/FuchsTelemetry.cs` (one `ActivitySource`, one `Meter`). - OpenTelemetry. Instrumentation is centralised in `Fuchs/Observability/FuchsTelemetry.cs` (one `ActivitySource`, one `Meter`).
- For meaningful operations: start an activity, record the matching counter/histogram, and log entry/result/timing/errors via injected `ILogger<T>` using **structured** placeholders (never interpolated strings). - For meaningful operations: start an activity, record the matching counter/histogram, and log entry/result/timing/errors via injected `ILogger<T>` using **structured** placeholders (never interpolated strings) — never `{@Foo}`; plain `Microsoft.Extensions.Logging` (unlike Serilog) does not strip the `@`, it becomes a literal part of the key.
- Always collected; OTLP export opt-in via `Fuchs:Telemetry:OtlpEndpoint`. No exporters that hard-fail without a collector. - Always collected; OTLP export opt-in via `Fuchs:Telemetry:OtlpEndpoint`. No exporters that hard-fail without a collector.
- **DB log sink (ADR 0024):** every `_logger.LogWarning`/`-Error`/`-Critical` call additionally lands in `[dbo].[fds__debuglog]` via the OCORE MultiLogger SQL sink (`fds__admin_logdebug_ext`), gated by `Fuchs:Logging:SqlSink:Enabled` (default on). `RequestLogScopeMiddleware` (`Fuchs/Logging/`) attributes every log line in a request to a request id/path/authenticated user automatically. Give a log call an explicit `EventId` (its `Name` becomes the row's `CodeReference`) when the call site should be individually greppable — see `FIS_SQLOptions`, `IntranetController.Do`'s catch-all, `PdfPreviewAsync`, and the banking-upload handlers for the pattern. Do not also call the retired `Fuchs_intranet.debug_log` — one `ILogger` call is the single write path; a second explicit DB write duplicates the row.
## Testing ## Testing
- xUnit in `Fuchs.Tests`. Testing must be **extensive**, not superficial: - xUnit in `Fuchs.Tests`. Testing must be **extensive**, not superficial:
+8 -4
View File
@@ -15,9 +15,10 @@
## Build & Test (workflow) ## Build & Test (workflow)
- Build app: `dotnet build Fuchs/Fuchs.csproj -c Debug`. Build all: `dotnet build Fuchs_Intranet.slnx -c Debug`. - Build app: `dotnet build Fuchs/Fuchs.csproj -c Debug`. Build all: `dotnet build Fuchs_Intranet.slnx -c Debug`.
- Build the database project whenever anything under `Fuchs_Database/` changes: `msbuild Fuchs_Database/FuchsDatabase.sqlproj -p:Configuration=Debug` (MSBuild from the Visual Studio install, e.g. `"C:\Program Files\Microsoft Visual Studio\18\Community\MSBuild\Current\Bin\MSBuild.exe"`; `dotnet build` cannot build the legacy SSDT project). T-SQL warnings are build errors there (ADR 0036): an unresolved or ambiguous column (SQL71502) fails the build, and a red database build is a failure like a red C# build. The working-tree SQL is **not** on any database until it is published, so this build is the only check the SQL gets before review.
- Frontend assets are source-built: run the gulp tasks in `Fuchs/` (`npx gulp min`, or `npx gulp all` when copied/static assets also need refreshing) whenever JS or SCSS/CSS sources change. The generated files under `Fuchs/wwwroot/web/` are what the app serves. - Frontend assets are source-built: run the gulp tasks in `Fuchs/` (`npx gulp min`, or `npx gulp all` when copied/static assets also need refreshing) whenever JS or SCSS/CSS sources change. The generated files under `Fuchs/wwwroot/web/` are what the app serves.
- Test: `dotnet test Fuchs.Tests/Fuchs.Tests.csproj -c Debug`. - Test: `dotnet test --project Fuchs.Tests/Fuchs.Tests.csproj -c Debug` (the root `global.json` opts `dotnet test` into the Microsoft.Testing.Platform runner that xunit.v3 needs on the .NET 10 SDK; the positional-path form no longer works).
- Always build **and** run the test suite before committing. The build emits many pre-existing analyzer/platform warnings (CA1416 etc.) — those are expected; only treat `: error` lines as failures. - Always build **and** run the test suite before committing. CA1416 (platform-compatibility) is suppressed solution-wide via the root `.editorconfig` — the app is Windows-only. The build may still emit other pre-existing analyzer warnings; only treat `: error` lines as failures.
- Commit only when asked. Co-author trailer, when requested: `Co-Authored-By: Codex <noreply@openai.com>`. - Commit only when asked. Co-author trailer, when requested: `Co-Authored-By: Codex <noreply@openai.com>`.
- The working tree may contain an untracked `Fuchs_Database/` SQL project — it is **not** part of app changes; never `git add -A` it into an unrelated commit. Stage explicit paths. - The working tree may contain an untracked `Fuchs_Database/` SQL project — it is **not** part of app changes; never `git add -A` it into an unrelated commit. Stage explicit paths.
@@ -38,7 +39,7 @@
## Services & Dependency Injection ## Services & Dependency Injection
- Business logic lives in **DI-registered services** under `Fuchs/Services/` behind interfaces, injected into `IntranetController`. Do not reintroduce static God-classes or pass the controller into helpers. - Business logic lives in **DI-registered services** under `Fuchs/Services/` behind interfaces, injected into `IntranetController`. Do not reintroduce static God-classes or pass the controller into helpers.
- Services: `IComService` (ProcessWeb Mailer API; honors the `Fuchs:Email:OverrideRecipient` dev safety net — see Configuration), `IPdfService`, `IInvoiceService`, `IReminderService`, `IReportService`, `IWidgetService`, `IBankingService`, `IMfrClientFactory`, `ISystemStatusService` (Admin module diagnostics: config snapshot + DB/Key Vault/blob/MFR connectivity probes + test-email; restricted to `fds_sys` > 4). Stateless ones are singletons; DB/request-scoped ones are scoped (see `Program.cs`). - Services: `IComService` (ProcessWeb Mailer API — contract at **https://processweb.de/api/mailer/fc**, read it and `Fuchs/Docs/Concepts/processweb-mailer-interface.md` before changing it. `ProcessWebComService` is a thin adapter over the OCORE_web `IPWMailerClientService` (`OCORE_web/comm/pwmailer/`, owns the HTTP contract; ADR 0029) with `FuchsMailerClient` overriding `ValidateMessage` for attachment limits; a send only counts as successful when the receipt's `attachmentsProcessed` matches the number sent (ADR 0028); honors the `Fuchs:Email:OverrideRecipient` dev safety net — see Configuration), `IPdfService`, `IInvoiceService`, `IReminderService`, `IReportService`, `IWidgetService`, `IBankingService`, `IMfrClientFactory`, `ISystemStatusService` (Admin module diagnostics: config snapshot + DB/Key Vault/blob/MFR connectivity probes + test-email; restricted to `fds_sys` > 4). Stateless ones are singletons; DB/request-scoped ones are scoped (see `Program.cs`).
- `FdsInvoiceData` / `FdsReminderData` are **pure data holders**; load/persist/render belongs in services. No `Task.Run(...).Wait()` sync-over-async. - `FdsInvoiceData` / `FdsReminderData` are **pure data holders**; load/persist/render belongs in services. No `Task.Run(...).Wait()` sync-over-async.
- Data access is SQL-first via OCORE helpers + stored procedures (no EF Core). - Data access is SQL-first via OCORE helpers + stored procedures (no EF Core).
@@ -49,6 +50,8 @@
## Database ## Database
- Schema source of truth: **`Fuchs_Database`** SSDT project. SQL-first backend (stored procs, table types e.g. `fds__tt__bankingtransactions`, functions via OCORE — no EF Core). - Schema source of truth: **`Fuchs_Database`** SSDT project. SQL-first backend (stored procs, table types e.g. `fds__tt__bankingtransactions`, functions via OCORE — no EF Core).
- Changing a proc signature or table type -> update the SSDT project **and** the calling C# together; verify every `[dbo].[…]` the backend calls exists in `Fuchs_Database`. - Changing a proc signature or table type -> update the SSDT project **and** the calling C# together; verify every `[dbo].[…]` the backend calls exists in `Fuchs_Database`.
- Objects the SQL references must be **in the project** (a referenced-but-missing function only shows up as an SSDT build error now; the `ocms_*` helpers are vendored copies from the OCMSsharp `ocms_mt_database` project). `Security/Logins.sql` holds placeholder logins only so the users bind — real passwords live in Key Vault.
- `Fuchs.Tests/SqlContractTests` checks every `[dbo].[…]` named in C# against the SSDT files (exists, `EXECUTE` binds, function arg counts; ADR 0036) — a proc signature change that breaks a caller fails the test suite.
## Bank statement parsing (MT940 + CAMT) ## Bank statement parsing (MT940 + CAMT)
- `MT940Parser` (external, SWIFT text) and **`CAMTParser`** (in-repo, ISO 20022 camt.052/053/054 XML) feed the same pipeline. - `MT940Parser` (external, SWIFT text) and **`CAMTParser`** (in-repo, ISO 20022 camt.052/053/054 XML) feed the same pipeline.
@@ -56,8 +59,9 @@
## Observability ## Observability
- OpenTelemetry. Instrumentation is centralised in `Fuchs/Observability/FuchsTelemetry.cs` (one `ActivitySource`, one `Meter`). - OpenTelemetry. Instrumentation is centralised in `Fuchs/Observability/FuchsTelemetry.cs` (one `ActivitySource`, one `Meter`).
- For meaningful operations: start an activity, record the matching counter/histogram, and log entry/result/timing/errors via injected `ILogger<T>` using **structured** placeholders (never interpolated strings). - For meaningful operations: start an activity, record the matching counter/histogram, and log entry/result/timing/errors via injected `ILogger<T>` using **structured** placeholders (never interpolated strings) — never `{@Foo}`; plain `Microsoft.Extensions.Logging` (unlike Serilog) does not strip the `@`, it becomes a literal part of the key.
- Always collected; OTLP export opt-in via `Fuchs:Telemetry:OtlpEndpoint`. No exporters that hard-fail without a collector. - Always collected; OTLP export opt-in via `Fuchs:Telemetry:OtlpEndpoint`. No exporters that hard-fail without a collector.
- **DB log sink (ADR 0024):** every `_logger.LogWarning`/`-Error`/`-Critical` call additionally lands in `[dbo].[fds__debuglog]` via the OCORE MultiLogger SQL sink (`fds__admin_logdebug_ext`), gated by `Fuchs:Logging:SqlSink:Enabled` (default on). `RequestLogScopeMiddleware` (`Fuchs/Logging/`) attributes every log line in a request to a request id/path/authenticated user automatically. Give a log call an explicit `EventId` (its `Name` becomes the row's `CodeReference`) when the call site should be individually greppable — see `FIS_SQLOptions`, `IntranetController.Do`'s catch-all, `PdfPreviewAsync`, and the banking-upload handlers for the pattern. Do not also call the retired `Fuchs_intranet.debug_log` — one `ILogger` call is the single write path; a second explicit DB write duplicates the row.
## Testing ## Testing
- xUnit in `Fuchs.Tests`. Testing must be **extensive**, not superficial: - xUnit in `Fuchs.Tests`. Testing must be **extensive**, not superficial:
+42 -3
View File
@@ -5,9 +5,9 @@ namespace Fuchs.Tests;
/// <summary> /// <summary>
/// Covers the pure (non-DB) logic behind the Admin module's user-management endpoints — /// Covers the pure (non-DB) logic behind the Admin module's user-management endpoints —
/// specifically the admin-initiated password-reset notification content, since the SQL-bound /// the admin-initiated password-reset and new-account welcome notification content, since the
/// parts of IntranetController.Admin.cs (fis_admin_* proc calls) can't be unit-tested per /// SQL-bound parts of IntranetController.AdminUsers.cs (fis_admin_* proc calls) can't be
/// repo convention and require a running database. /// unit-tested per repo convention and require a running database.
/// </summary> /// </summary>
public class AdminUserManagementTests public class AdminUserManagementTests
{ {
@@ -40,4 +40,43 @@ public class AdminUserManagementTests
Assert.False(string.IsNullOrWhiteSpace(subject)); Assert.False(string.IsNullOrWhiteSpace(subject));
Assert.Contains("somepassword", html); Assert.Contains("somepassword", html);
} }
[Fact]
public void BuildWelcomeEmail_ValidInput_ContainsNameEmailAndPassword()
{
var (subject, html) = IntranetController.BuildWelcomeEmail("Max Mustermann", "max@example.com", "Ab12Cd34");
Assert.Contains("Zugang", subject);
Assert.Contains("Max Mustermann", html);
Assert.Contains("max@example.com", html);
Assert.Contains("Ab12Cd34", html);
}
[Theory]
[InlineData("<script>alert(1)</script>", "&lt;script&gt;alert(1)&lt;/script&gt;")]
[InlineData("O'Brien&Co", "O&#39;Brien&amp;Co")]
public void BuildWelcomeEmail_PasswordWithHtmlSpecialChars_IsHtmlEncoded(string rawPassword, string encoded)
{
var (_, html) = IntranetController.BuildWelcomeEmail("Test User", "test@example.com", rawPassword);
Assert.Contains(encoded, html);
Assert.DoesNotContain(rawPassword, html);
}
[Fact]
public void BuildWelcomeEmail_EmailWithHtmlSpecialChars_IsHtmlEncoded()
{
var (_, html) = IntranetController.BuildWelcomeEmail("Test User", "a&b@example.com", "somepassword");
Assert.Contains("a&amp;b@example.com", html);
}
[Fact]
public void BuildWelcomeEmail_EmptyName_StillProducesValidContent()
{
var (subject, html) = IntranetController.BuildWelcomeEmail("", "test@example.com", "somepassword");
Assert.False(string.IsNullOrWhiteSpace(subject));
Assert.Contains("somepassword", html);
}
} }
+199
View File
@@ -172,4 +172,203 @@ public class AzureBlobStorageServiceTests
Assert.Null(result); Assert.Null(result);
Assert.True(delta >= 1, "fuchs.blobstorage.uploads.failed counter should have been incremented on a failed upload."); Assert.True(delta >= 1, "fuchs.blobstorage.uploads.failed counter should have been incremented on a failed upload.");
} }
// ── Cancellation: a caller-cancelled token (app shutdown during the startup backfill) must
// surface as OperationCanceledException, not be logged/reported as a storage failure ────
[Fact]
public async Task UploadInvoicePdfAsync_CallerCancelled_ThrowsOperationCanceled()
{
var (service, _, blob) = CreateMockedClientChain();
using var cts = new CancellationTokenSource();
cts.Cancel();
blob.Setup(b => b.UploadAsync(It.IsAny<Stream>(), It.IsAny<bool>(), It.IsAny<CancellationToken>()))
.ThrowsAsync(new TaskCanceledException("The operation was canceled."));
var svc = new AzureBlobStorageService(service.Object, CreateSettings(), NullLogger<AzureBlobStorageService>.Instance);
await Assert.ThrowsAnyAsync<OperationCanceledException>(
() => svc.UploadInvoicePdfAsync("INV9", "Rechnung_INV9.pdf", new byte[] { 1 }, null, cts.Token));
}
[Fact]
public async Task UploadInvoicePdfAsync_TimeoutWithoutCallerCancel_ReturnsNull()
{
var (service, _, blob) = CreateMockedClientChain();
blob.Setup(b => b.UploadAsync(It.IsAny<Stream>(), It.IsAny<bool>(), It.IsAny<CancellationToken>()))
.ThrowsAsync(new TaskCanceledException("network timeout"));
var svc = new AzureBlobStorageService(service.Object, CreateSettings(), NullLogger<AzureBlobStorageService>.Instance);
Uri? result = await svc.UploadInvoicePdfAsync("INV9", "Rechnung_INV9.pdf", new byte[] { 1 });
Assert.Null(result);
}
[Fact]
public async Task ExistsAsync_CallerCancelled_ThrowsOperationCanceled()
{
var (service, _, blob) = CreateMockedClientChain();
using var cts = new CancellationTokenSource();
cts.Cancel();
blob.Setup(b => b.ExistsAsync(It.IsAny<CancellationToken>()))
.ThrowsAsync(new TaskCanceledException("The operation was canceled."));
var svc = new AzureBlobStorageService(service.Object, CreateSettings(), NullLogger<AzureBlobStorageService>.Instance);
await Assert.ThrowsAnyAsync<OperationCanceledException>(
() => svc.ExistsAsync("fuchs-invoices", "INV9", "Rechnung_INV9.pdf", cts.Token));
}
[Theory]
[InlineData(false)] // storage error
[InlineData(true)] // timeout-style cancellation while the caller's token is still live
public async Task ExistsAsync_CheckFailsWithoutCallerCancel_ReturnsFalse(bool timeout)
{
var (service, _, blob) = CreateMockedClientChain();
Exception ex = timeout ? new TaskCanceledException("timeout") : new RequestFailedException("boom");
blob.Setup(b => b.ExistsAsync(It.IsAny<CancellationToken>())).ThrowsAsync(ex);
var svc = new AzureBlobStorageService(service.Object, CreateSettings(), NullLogger<AzureBlobStorageService>.Instance);
Assert.False(await svc.ExistsAsync("fuchs-invoices", "INV9", "Rechnung_INV9.pdf"));
}
// ── CopyInvoicePdfToBackupAsync ────────────────────────────────────────────
[Fact]
public async Task CopyInvoicePdfToBackupAsync_NoClient_ReturnsNullWithoutThrowing()
{
var svc = new AzureBlobStorageService(client: null, CreateSettings(), NullLogger<AzureBlobStorageService>.Instance);
Uri? result = await svc.CopyInvoicePdfToBackupAsync("INV1", "Rechnung_INV1.pdf");
Assert.Null(result);
}
[Fact]
public async Task CopyInvoicePdfToBackupAsync_NoExistingBlob_ReturnsNullWithoutCopying()
{
var (service, container, blob) = CreateMockedClientChain();
blob.Setup(b => b.ExistsAsync(It.IsAny<CancellationToken>()))
.ReturnsAsync(Response.FromValue(false, Mock.Of<Response>()));
var svc = new AzureBlobStorageService(service.Object, CreateSettings(), NullLogger<AzureBlobStorageService>.Instance);
Uri? result = await svc.CopyInvoicePdfToBackupAsync("INV1", "Rechnung_INV1.pdf");
Assert.Null(result);
blob.Verify(b => b.StartCopyFromUriAsync(It.IsAny<Uri>(), It.IsAny<BlobCopyFromUriOptions>(), It.IsAny<CancellationToken>()),
Times.Never);
blob.Verify(b => b.DeleteIfExistsAsync(
It.IsAny<DeleteSnapshotsOption>(), It.IsAny<BlobRequestConditions>(), It.IsAny<CancellationToken>()), Times.Never);
}
/// <summary>
/// Source blob exists; the backup blob accepts a server-side copy whose operation completes
/// immediately. Returns the mocks needed for verification.
/// </summary>
private static (Mock<BlobServiceClient> service, Mock<BlobClient> sourceBlob, Mock<BlobClient> backupBlob, Mock<CopyFromUriOperation> copy)
CreateCopyChain(Uri sourceUri, Uri backupUri)
{
var (service, container, sourceBlob) = CreateMockedClientChain();
sourceBlob.Setup(b => b.Uri).Returns(sourceUri);
sourceBlob.Setup(b => b.ExistsAsync(It.IsAny<CancellationToken>()))
.ReturnsAsync(Response.FromValue(true, Mock.Of<Response>()));
var copy = new Mock<CopyFromUriOperation>();
copy.Setup(c => c.WaitForCompletionAsync(It.IsAny<CancellationToken>()))
.ReturnsAsync(Response.FromValue(3L, Mock.Of<Response>()));
var backupBlob = new Mock<BlobClient>();
backupBlob.Setup(b => b.Uri).Returns(backupUri);
backupBlob.Setup(b => b.StartCopyFromUriAsync(It.IsAny<Uri>(), It.IsAny<BlobCopyFromUriOptions>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(copy.Object);
container.Setup(c => c.GetBlobClient(It.Is<string>(n => n.StartsWith("INV1/backup_"))))
.Returns(backupBlob.Object);
return (service, sourceBlob, backupBlob, copy);
}
[Fact]
public async Task CopyInvoicePdfToBackupAsync_ExistingBlob_CopiesServerSideUnderBackupNameAndKeepsOriginal()
{
var sourceUri = new Uri("https://test.blob.core.windows.net/test-invoices/INV1/Rechnung_INV1.pdf");
var backupUri = new Uri("https://test.blob.core.windows.net/test-invoices/INV1/backup_20260917_120000_Rechnung_INV1.pdf");
var (service, sourceBlob, backupBlob, copy) = CreateCopyChain(sourceUri, backupUri);
long succeeded = 0;
using var listener = new MeterListener
{
InstrumentPublished = (inst, l) =>
{
if (inst.Meter.Name == FuchsMeterName && inst.Name == "fuchs.blobstorage.backupcopies")
l.EnableMeasurementEvents(inst);
}
};
listener.SetMeasurementEventCallback<long>((_, value, _, _) => Interlocked.Add(ref succeeded, value));
listener.Start();
var svc = new AzureBlobStorageService(service.Object, CreateSettings(), NullLogger<AzureBlobStorageService>.Instance);
Uri? result = await svc.CopyInvoicePdfToBackupAsync("INV1", "Rechnung_INV1.pdf");
Assert.Equal(backupUri, result);
// The bytes must never pass through the process: server-side copy from the source URI only.
backupBlob.Verify(b => b.StartCopyFromUriAsync(sourceUri, It.IsAny<BlobCopyFromUriOptions>(), It.IsAny<CancellationToken>()), Times.Once);
copy.Verify(c => c.WaitForCompletionAsync(It.IsAny<CancellationToken>()), Times.Once);
sourceBlob.Verify(b => b.DownloadContentAsync(It.IsAny<CancellationToken>()), Times.Never);
backupBlob.Verify(b => b.UploadAsync(It.IsAny<Stream>(), It.IsAny<bool>(), It.IsAny<CancellationToken>()), Times.Never);
// The original stays: it remains the current archive until the next regeneration overwrites it.
sourceBlob.Verify(b => b.DeleteIfExistsAsync(
It.IsAny<DeleteSnapshotsOption>(), It.IsAny<BlobRequestConditions>(), It.IsAny<CancellationToken>()), Times.Never);
Assert.True(succeeded >= 1, "fuchs.blobstorage.backupcopies counter should have been incremented.");
}
[Fact]
public async Task CopyInvoicePdfToBackupAsync_CopyFails_ReturnsNullAndIncrementsFailedCounter()
{
var sourceUri = new Uri("https://test.blob.core.windows.net/test-invoices/INV1/Rechnung_INV1.pdf");
var backupUri = new Uri("https://test.blob.core.windows.net/test-invoices/INV1/backup_x_Rechnung_INV1.pdf");
var (service, sourceBlob, backupBlob, copy) = CreateCopyChain(sourceUri, backupUri);
copy.Setup(c => c.WaitForCompletionAsync(It.IsAny<CancellationToken>()))
.ThrowsAsync(new RequestFailedException("copy aborted"));
long failed = 0;
using var listener = new MeterListener
{
InstrumentPublished = (inst, l) =>
{
if (inst.Meter.Name == FuchsMeterName && inst.Name == "fuchs.blobstorage.backupcopies.failed")
l.EnableMeasurementEvents(inst);
}
};
listener.SetMeasurementEventCallback<long>((_, value, _, _) => Interlocked.Add(ref failed, value));
listener.Start();
var svc = new AzureBlobStorageService(service.Object, CreateSettings(), NullLogger<AzureBlobStorageService>.Instance);
Uri? result = await svc.CopyInvoicePdfToBackupAsync("INV1", "Rechnung_INV1.pdf");
Assert.Null(result);
Assert.True(failed >= 1, "fuchs.blobstorage.backupcopies.failed counter should have been incremented.");
}
[Fact]
public async Task CopyInvoicePdfToBackupAsync_ClientThrows_ReturnsNullAndIncrementsFailedCounter()
{
var (service, container, blob) = CreateMockedClientChain();
blob.Setup(b => b.ExistsAsync(It.IsAny<CancellationToken>()))
.ThrowsAsync(new RequestFailedException("simulated storage failure"));
long delta = 0;
using var listener = new MeterListener
{
InstrumentPublished = (inst, l) =>
{
if (inst.Meter.Name == FuchsMeterName && inst.Name == "fuchs.blobstorage.backupcopies.failed")
l.EnableMeasurementEvents(inst);
}
};
listener.SetMeasurementEventCallback<long>((_, value, _, _) => Interlocked.Add(ref delta, value));
listener.Start();
var svc = new AzureBlobStorageService(service.Object, CreateSettings(), NullLogger<AzureBlobStorageService>.Instance);
Uri? result = await svc.CopyInvoicePdfToBackupAsync("INV2", "Rechnung_INV2.pdf");
Assert.Null(result);
Assert.True(delta >= 1, "fuchs.blobstorage.backupcopies.failed counter should have been incremented.");
}
} }
+99
View File
@@ -0,0 +1,99 @@
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Text.RegularExpressions;
using Fuchs.Controllers;
using Xunit;
namespace Fuchs.Tests;
/// <summary>
/// <c>inv/datev</c> (HandleDatev) called <c>fds__getDatevExports @tgtdate, @mode, @authuser</c> positionally
/// against the signature (@tgtdate, @mode, @files, @authuser): the user id landed in <c>@files bit</c> and
/// <c>@authuser</c> was never supplied, so every call failed (8114 / 201). The command now binds by name.
/// These tests check the command against the real SSDT procedure so a future signature change is caught.
/// </summary>
public class DatevOverviewCommandTests
{
private const string ProcFile = "Stored Procedures/fds__getDatevExports.sql";
private static readonly Regex ParamDecl = new(
@"@(?<name>\w+)\s+\w+(\s*\([^)]*\))?\s*(?<def>=)?",
RegexOptions.Compiled);
private static readonly Regex ExecArg = new(
@"(?<name>@\w+)\s*(?<named>=)?",
RegexOptions.Compiled);
private static string? FindDatabaseProject()
{
for (var dir = new DirectoryInfo(AppContext.BaseDirectory); dir != null; dir = dir.Parent)
{
string candidate = Path.Combine(dir.FullName, "Fuchs_Database", "dbo");
if (Directory.Exists(candidate)) return candidate;
}
return null;
}
/// <summary>(name, hasDefault) of each parameter, in declaration order.</summary>
private static List<(string Name, bool HasDefault)> ProcParameters()
{
string? root = FindDatabaseProject();
if (root == null) Assert.Skip("Fuchs_Database SSDT project not present in this checkout.");
string sql = File.ReadAllText(Path.Combine(root!, ProcFile));
int start = sql.IndexOf("[fds__getDatevExports]", StringComparison.OrdinalIgnoreCase);
int end = Regex.Match(sql[start..], @"^\s*AS\s*$", RegexOptions.Multiline).Index + start;
return ParamDecl.Matches(sql[start..end])
.Select(m => ("@" + m.Groups["name"].Value, m.Groups["def"].Success))
.ToList();
}
/// <summary>
/// Binding problems of an EXECUTE text against the procedure: positional arguments, names the proc
/// does not declare, and required (no default) parameters that are not supplied.
/// </summary>
internal static List<string> BindingProblems(string execSql, List<(string Name, bool HasDefault)> procParams)
{
string args = execSql[(execSql.IndexOf(']', execSql.IndexOf("fds__getDatevExports", StringComparison.Ordinal)) + 1)..];
var problems = new List<string>();
var supplied = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
foreach (var part in args.TrimEnd(';', ' ').Split(',', StringSplitOptions.TrimEntries | StringSplitOptions.RemoveEmptyEntries))
{
var m = ExecArg.Match(part);
if (!m.Success || !m.Groups["named"].Success) { problems.Add($"positional argument '{part}'"); continue; }
string name = m.Groups["name"].Value;
if (!procParams.Any(p => p.Name.Equals(name, StringComparison.OrdinalIgnoreCase)))
problems.Add($"unknown parameter {name}");
supplied.Add(name);
}
problems.AddRange(procParams.Where(p => !p.HasDefault && !supplied.Contains(p.Name))
.Select(p => $"required parameter {p.Name} not supplied"));
return problems;
}
[Fact]
public void ProcParameters_RealSsdtFile_MatchesKnownSignature()
{
var ps = ProcParameters();
Assert.Equal(new[] { "@tgtdate", "@mode", "@files", "@authuser" }, ps.Select(p => p.Name));
Assert.Equal(new[] { false, true, false, false }, ps.Select(p => p.HasDefault));
}
[Fact]
public void DatevOverviewSql_AgainstRealProc_BindsEveryRequiredParameterByName()
=> Assert.Empty(BindingProblems(IntranetController.DatevOverviewSql, ProcParameters()));
[Theory]
[InlineData("EXECUTE [dbo].[fds__getDatevExports] @tgtdate, @mode, @authuser;")] // the former call
[InlineData("EXECUTE [dbo].[fds__getDatevExports] @tgtdate = @tgtdate, @mode = @mode, @files = 0;")] // @authuser missing
[InlineData("EXECUTE [dbo].[fds__getDatevExports] @tgtdate = @tgtdate, @files = 0, @user = @authuser;")] // unknown name
public void BindingProblems_MisboundCommand_IsReported(string execSql)
=> Assert.NotEmpty(BindingProblems(execSql, ProcParameters()));
[Fact]
public void DatevOverviewTables_NamesAllFourResultSetsInProcOrder()
// fds__getDatevExports returns header, invoice documents, bookings, debtors — the same four sets
// FdsMfr.GetDatevZip names admin/inv/buchungen/debitoren. Three names shifted every set by one.
=> Assert.Equal(new[] { "admin", "files", "invoices", "debits" }, IntranetController.DatevOverviewTables);
}
+388
View File
@@ -0,0 +1,388 @@
using System;
using System.Collections.Concurrent;
using System.Collections.Generic;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using Fuchs.intranet;
using Fuchs.Services;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.Logging.Abstractions;
using MigraDoc.DocumentObjectModel;
using Newtonsoft.Json;
using Newtonsoft.Json.Linq;
using OCORE.security;
using Xunit;
using static OCORE.OCORE_dictionaries;
namespace Fuchs.Tests;
/// <summary>
/// The editor fires patches, state fetches, previews and saves concurrently for one draft token. The
/// session payload (<c>JArray</c>/<c>JObject</c>, history list) is not thread-safe, so the draft
/// services serialise every access on <c>SyncRoot</c> and hand out snapshots. Regression for the
/// production "Collection was modified" crash in <c>InvoiceSetStructure.RecomputeLevels</c>
/// (inv/dpatch, 2026-10-01). Flushes are additionally serialised on <c>FlushGate</c>: the
/// create/update decision in <c>FlushToDbAsync</c> is a read-then-write across an await, so two
/// overlapping saves of a new draft would otherwise both create a DB row.
/// </summary>
public class DraftSessionConcurrencyTests
{
/// <summary>
/// Registration double: records every call, hands out a fresh id per <b>create</b> and echoes the
/// given id for an update — like the DB, so a duplicate create is visible as a second distinct id.
/// <see cref="Block"/>, when set, parks the first registration until released, which lets a test
/// pin down the exact interleaving instead of hoping for one.
/// </summary>
private sealed class FakeInvoiceService : IInvoiceService
{
private int _ids;
public readonly ConcurrentQueue<(bool change, string invId)> Calls = new();
public TaskCompletionSource? Block;
public readonly TaskCompletionSource Entered = new(TaskCreationOptions.RunContinuationsAsynchronously);
public Exception? Throw;
public async Task<FdsInvoiceData> RegisterInvoiceAsync(FdsInvoiceData invoice, bool change, string invId, string userAccountId, DatabaseSecurity dbSec)
{
Calls.Enqueue((change, invId));
Entered.TrySetResult();
if (Block != null) { var b = Block; Block = null; await b.Task; }
if (Throw != null) { var ex = Throw; Throw = null; throw ex; }
string id = change ? invId : "INV" + Interlocked.Increment(ref _ids);
invoice.InvoiceRegistration = new GenericObjectDictionary(new Dictionary<string, object> { ["Id"] = id });
return invoice;
}
public Task<FdsInvoiceData> LoadInvoiceAsync(string id, string u, DatabaseSecurity s) => throw new NotSupportedException();
public Task<Document> GenerateInvoicePdfAsync(FdsInvoiceData i, bool d) => Task.FromResult(new Document());
public Task<byte[]> RenderInvoicePdfBytesAsync(FdsInvoiceData i, bool d) => throw new NotSupportedException();
public Task<byte[]> StoreInvoiceDocumentFileAsync(FdsInvoiceData i, bool d, string u, DatabaseSecurity s) => throw new NotSupportedException();
public Task<byte[]?> GetInvoiceFileAsync(FdsInvoiceData i, bool d, fds.IFdsMfr m) => throw new NotSupportedException();
}
private sealed class FakeReminderService : IReminderService
{
private int _ids;
public readonly ConcurrentQueue<(bool change, string remId)> Calls = new();
public TaskCompletionSource? Block;
public readonly TaskCompletionSource Entered = new(TaskCreationOptions.RunContinuationsAsynchronously);
public async Task<FdsReminderData> RegisterReminderAsync(FdsReminderData reminder, bool change, string remId, string userAccountId, DatabaseSecurity dbSec)
{
Calls.Enqueue((change, remId));
Entered.TrySetResult();
if (Block != null) { var b = Block; Block = null; await b.Task; }
string id = change ? remId : "REM" + Interlocked.Increment(ref _ids);
reminder.ReminderRegistration = new GenericObjectDictionary(new Dictionary<string, object> { ["Id"] = id });
return reminder;
}
public Document GenerateReminderPdf(FdsReminderData reminder, bool draft) => new();
public Task<FdsReminderData> LoadReminderAsync(string id, string u, DatabaseSecurity s) => throw new NotSupportedException();
public Task<byte[]> RenderReminderPdfBytesAsync(FdsReminderData r, bool d) => throw new NotSupportedException();
public Task<byte[]> StoreReminderDocumentFileAsync(FdsReminderData r, bool d, string u, DatabaseSecurity s) => throw new NotSupportedException();
public Task<byte[]> GetReminderFileAsync(FdsReminderData r, bool d, fds.IFdsMfr m, string u, DatabaseSecurity s) => throw new NotSupportedException();
public Task<(System.IO.FileInfo? file, byte[]? content)> GetStoredFileAsync(string id, string u, DatabaseSecurity s) => throw new NotSupportedException();
}
private static (InvoiceDraftEditService svc, FakeInvoiceService inv) NewInvoiceService()
{
var inv = new FakeInvoiceService();
return (new InvoiceDraftEditService(new InvoiceDraftCache(new ConfigurationBuilder().Build()), inv, NullLogger<InvoiceDraftEditService>.Instance), inv);
}
private static (ReminderDraftEditService svc, FakeReminderService rem) NewReminderService()
{
var rem = new FakeReminderService();
return (new ReminderDraftEditService(new ReminderDraftCache(new ConfigurationBuilder().Build()), rem, NullLogger<ReminderDraftEditService>.Instance), rem);
}
private static JObject InvoicePayload() => JObject.Parse(@"{
'admin':{'p13b':false,'type':'r','paymentterms':'10wd'},
'new':{'invoiceemail':'a@b.de','invoiceaddress':'Weg 1','invoicetitle':'Rechnung'},
'req':[{'Id':'1','text':'Auftrag','itm':[{'id':'900','typ':'set','vt':0,'vv':0,'vat':'19%'},
{'id':'901','typ':'material','vt':100,'vv':19,'vat':'19%','SetItmId':'900'}],
'items':[{'id':'900','type':'set','total_net':0,'vat':'19%'},{'id':'901','type':'material','total_net':100,'vat':'19%'}]}]
}");
private static JObject ReminderPayload() => JObject.Parse(@"{
'rem':{'invid':'INV5','type':'R','invoiceid':'R2026-1','invoicedate':'2026-06-01'},
'new':{'invoiceemail':'a@b.de','invoiceaddress':'Weg 1','subject':'Zahlungserinnerung','amount':119,'amount_payed':0}
}");
private static JObject Block(string id, decimal net) => JObject.Parse(
"{'Id':'" + id + "','text':'Auftrag " + id + "','itm':[{'id':'i" + id + "','typ':'material','vt':" + net + ",'vv':0,'vat':'19%'}]," +
"'items':[{'id':'i" + id + "','type':'material','total_net':" + net + ",'vat':'19%'}]}");
/// <summary>Runs <paramref name="writers"/> and <paramref name="readers"/> concurrently from a common start gate.</summary>
private static async Task RunConcurrently(IEnumerable<Action> writers, IEnumerable<Action> readers)
{
using var gate = new ManualResetEventSlim(false);
var tasks = writers.Concat(readers)
.Select(a => Task.Factory.StartNew(() => { gate.Wait(); a(); }, TaskCreationOptions.LongRunning))
.ToList();
gate.Set();
await Task.WhenAll(tasks);
}
private static Task WithTimeout(Task t) => t.WaitAsync(TimeSpan.FromSeconds(10));
private static Task<T> WithTimeout<T>(Task<T> t) => t.WaitAsync(TimeSpan.FromSeconds(10));
// ── payload lock ───────────────────────────────────────────────────────────
[Theory]
[InlineData("block.replace")] // inserts new blocks → s.Req.Add (the crash: RecomputeLevels enumerating Req)
[InlineData("block.order")] // s.Req.Clear() + re-add while others enumerate
public async Task ApplyPatch_ConcurrentPatchesAndStateReads_NeverThrowAndCountEveryPatch(string target)
{
const int patches = 200;
var (svc, _) = NewInvoiceService();
var s = svc.OpenFromPayload(InvoicePayload(), "user1");
if (target == "block.order")
for (int i = 0; i < 4; i++) svc.ApplyPatch(s.Token, new InvoiceDraftDelta { Target = "block.replace", Ref = "b" + i, Value = Block("b" + i, 10) });
int baseVersion = s.Version;
var writers = Enumerable.Range(0, patches).Select<int, Action>(i => () =>
{
var delta = target == "block.replace"
? new InvoiceDraftDelta { Target = target, Ref = "n" + i, Value = Block("n" + i, 1) }
: new InvoiceDraftDelta { Target = target, Value = new JArray(i % 2 == 0 ? new[] { "b3", "b2", "1" } : new[] { "1", "b0", "b1" }) };
Assert.NotNull(svc.ApplyPatch(s.Token, delta));
});
var readers = Enumerable.Range(0, 50).Select<int, Action>(_ => () =>
{
for (int k = 0; k < 5; k++)
{
JsonConvert.SerializeObject(svc.BuildState(s)); // what JSONAsync does after BuildState returns
_ = svc.GetHistory(s.Token).Count;
}
});
await RunConcurrently(writers, readers);
// Every applied patch bumps the version exactly once and leaves one history entry (no lost
// updates). A reorder to the order that is already current is a no-op by design, so for
// block.order fewer than `patches` may apply.
Assert.Equal(s.Version, svc.GetHistory(s.Token).Count);
if (target == "block.order")
{
Assert.InRange(s.Version, baseVersion + 1, baseVersion + patches);
Assert.Equal(5, s.Req.Count); // reorders never lose or duplicate a block
}
else
{
Assert.Equal(baseVersion + patches, s.Version);
Assert.Equal(1 + patches, s.Req.Count);
Assert.Equal(100m + patches, s.Sums.TotalNet);
}
}
[Fact]
public async Task RenderPreviewAndFlush_ConcurrentWithPatches_NeverThrowAndCreateExactlyOnce()
{
var (svc, inv) = NewInvoiceService();
var s = svc.OpenFromPayload(InvoicePayload(), "user1");
var results = new ConcurrentBag<DraftFlushResult<FdsInvoiceData>>();
var writers = Enumerable.Range(0, 100).Select<int, Action>(i => () =>
svc.ApplyPatch(s.Token, new InvoiceDraftDelta { Target = "block.replace", Ref = "n" + i, Value = Block("n" + i, 1) }));
var readers = Enumerable.Range(0, 20).Select<int, Action>(i => () =>
{
Assert.NotNull(svc.RenderPreviewAsync(s.Token).GetAwaiter().GetResult());
results.Add(svc.FlushToDbAsync(s.Token, "user1", null!).GetAwaiter().GetResult()!);
});
await RunConcurrently(writers, readers);
Assert.Equal(101, s.Req.Count);
// 20 overlapping saves of a brand-new draft: exactly one fds__createInvoice, every other save
// an update of that row — never a second orphaned invoice.
Assert.Equal(20, inv.Calls.Count);
Assert.Single(inv.Calls, c => !c.change);
Assert.Equal("INV1", s.InvId);
Assert.All(inv.Calls.Where(c => c.change), c => Assert.Equal("INV1", c.invId));
Assert.All(results, r => Assert.Equal("INV1", r.Data.Id));
Assert.Single(results, r => !r.Updated);
}
// ── flush gate ─────────────────────────────────────────────────────────────
[Fact]
public async Task FlushToDbAsync_SecondSaveOverlapsFirstRegistration_WaitsThenUpdatesInsteadOfCreating()
{
var (svc, inv) = NewInvoiceService();
var s = svc.OpenFromPayload(InvoicePayload(), "user1");
var block = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
inv.Block = block; // the fake clears Block once it has parked on it
var first = svc.FlushToDbAsync(s.Token, "user1", null!);
await WithTimeout(inv.Entered.Task); // first save is inside the DB registration, InvId still empty
var second = svc.FlushToDbAsync(s.Token, "user1", null!);
// While the first registration is in flight the second save must not reach the registration at
// all (it would see the empty InvId and create a second row). A false pass here is possible
// only under pathological scheduling; a false failure is not.
await Task.Delay(150);
Assert.False(second.IsCompleted);
Assert.Single(inv.Calls);
block.SetResult();
var r1 = (await WithTimeout(first))!;
var r2 = (await WithTimeout(second))!;
Assert.Equal(new[] { (false, ""), (true, "INV1") }, inv.Calls.ToArray());
Assert.False(r1.Updated); Assert.Equal("INV1", r1.Data.Id);
Assert.True(r2.Updated); Assert.Equal("INV1", r2.Data.Id);
Assert.Equal("INV1", s.InvId);
}
[Fact]
public async Task FlushToDbAsync_SecondSaveCarriesPatchesMadeDuringFirst()
{
var (svc, inv) = NewInvoiceService();
var s = svc.OpenFromPayload(InvoicePayload(), "user1");
var block = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
inv.Block = block; // the fake clears Block once it has parked on it
var first = svc.FlushToDbAsync(s.Token, "user1", null!);
await WithTimeout(inv.Entered.Task);
svc.ApplyPatch(s.Token, new InvoiceDraftDelta { Target = "email", Value = "neu@x.de" }); // edits are not blocked by a running save
var second = svc.FlushToDbAsync(s.Token, "user1", null!);
block.SetResult();
var r1 = (await WithTimeout(first))!;
var r2 = (await WithTimeout(second))!;
// The waiting save snapshots after it acquires the gate, so it persists the newer state —
// serialising never makes a save write stale data.
Assert.Equal("a@b.de", r1.Data.RawInvoiceEmail);
Assert.Equal("neu@x.de", r2.Data.RawInvoiceEmail);
Assert.True(r2.Updated);
}
[Fact]
public async Task FlushToDbAsync_RegistrationThrows_ReleasesGateAndNextSaveCreates()
{
var (svc, inv) = NewInvoiceService();
var s = svc.OpenFromPayload(InvoicePayload(), "user1");
inv.Throw = new InvalidOperationException("db down");
await Assert.ThrowsAsync<InvalidOperationException>(() => svc.FlushToDbAsync(s.Token, "user1", null!));
// The gate must be released by the failed save; the next one gets through and, since no id
// was committed, still runs as the create.
var r = (await WithTimeout(svc.FlushToDbAsync(s.Token, "user1", null!)))!;
Assert.False(r.Updated);
Assert.Equal("INV1", r.Data.Id);
Assert.Equal("INV1", s.InvId);
Assert.Equal(2, inv.Calls.Count);
}
[Fact]
public async Task FlushToDbAsync_UnknownToken_ReturnsNullWithoutRegistering()
{
var (svc, inv) = NewInvoiceService();
Assert.Null(await svc.FlushToDbAsync("ghost", "user1", null!));
Assert.Empty(inv.Calls);
}
[Fact]
public async Task ReminderFlushToDbAsync_SecondSaveOverlapsFirstRegistration_WaitsThenUpdatesInsteadOfCreating()
{
var (svc, rem) = NewReminderService();
var s = svc.OpenFromPayload(ReminderPayload(), "user1");
var block = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
rem.Block = block; // the fake clears Block once it has parked on it
var first = svc.FlushToDbAsync(s.Token, "user1", null!);
await WithTimeout(rem.Entered.Task);
var second = svc.FlushToDbAsync(s.Token, "user1", null!);
await Task.Delay(150);
Assert.False(second.IsCompleted);
Assert.Single(rem.Calls);
block.SetResult();
var r1 = (await WithTimeout(first))!;
var r2 = (await WithTimeout(second))!;
Assert.Equal(new[] { (false, ""), (true, "REM1") }, rem.Calls.ToArray());
Assert.False(r1.Updated); Assert.Equal("REM1", r1.Data.Id);
Assert.True(r2.Updated); Assert.Equal("REM1", r2.Data.Id);
Assert.Equal("REM1", s.RemId);
}
[Fact]
public async Task ReminderFlushToDbAsync_ManyConcurrentSaves_CreateExactlyOnce()
{
var (svc, rem) = NewReminderService();
var s = svc.OpenFromPayload(ReminderPayload(), "user1");
await RunConcurrently(
Enumerable.Range(0, 20).Select<int, Action>(_ => () => svc.FlushToDbAsync(s.Token, "user1", null!).GetAwaiter().GetResult()),
Enumerable.Empty<Action>());
Assert.Equal(20, rem.Calls.Count);
Assert.Single(rem.Calls, c => !c.change);
Assert.Equal("REM1", s.RemId);
}
// ── snapshots ──────────────────────────────────────────────────────────────
[Fact]
public void BuildState_IsSnapshot_LaterPatchDoesNotChangeReturnedPayload()
{
var (svc, _) = NewInvoiceService();
var s = svc.OpenFromPayload(InvoicePayload(), "user1");
string before = JsonConvert.SerializeObject(svc.BuildState(s));
var state = svc.BuildState(s);
svc.ApplyPatch(s.Token, new InvoiceDraftDelta { Target = "block.replace", Ref = "x", Value = Block("x", 5) });
svc.ApplyPatch(s.Token, new InvoiceDraftDelta { Target = "email", Value = "neu@x.de" });
Assert.Equal(before, JsonConvert.SerializeObject(state));
Assert.Contains("neu@x.de", JsonConvert.SerializeObject(svc.BuildState(s)));
}
[Fact]
public void GetHistory_IsSnapshot_LaterPatchDoesNotGrowReturnedList()
{
var (svc, _) = NewInvoiceService();
var s = svc.OpenFromPayload(InvoicePayload(), "user1");
svc.ApplyPatch(s.Token, new InvoiceDraftDelta { Target = "email", Value = "x@y.de" });
var history = svc.GetHistory(s.Token);
svc.ApplyPatch(s.Token, new InvoiceDraftDelta { Target = "email", Value = "z@y.de" });
Assert.Single(history);
Assert.Equal(2, svc.GetHistory(s.Token).Count);
}
[Theory]
[InlineData("")]
[InlineData("unknown-token")]
public void GetHistory_UnknownToken_ReturnsEmpty(string token)
{
Assert.Empty(NewInvoiceService().svc.GetHistory(token));
Assert.Empty(NewReminderService().svc.GetHistory(token));
}
[Fact]
public async Task ReminderApplyPatch_ConcurrentPatchesAndStateReads_NeverThrowAndCountEveryPatch()
{
const int patches = 200;
var (svc, _) = NewReminderService();
var s = svc.OpenFromPayload(ReminderPayload(), "user1");
var writers = Enumerable.Range(0, patches).Select<int, Action>(i => () =>
Assert.NotNull(svc.ApplyPatch(s.Token, new ReminderDraftDelta { Target = i % 2 == 0 ? "subject" : "amount_payed", Value = i % 2 == 0 ? "S" + i : "1" })));
var readers = Enumerable.Range(0, 50).Select<int, Action>(_ => () =>
{
for (int k = 0; k < 5; k++)
{
JsonConvert.SerializeObject(svc.BuildState(s));
_ = svc.GetHistory(s.Token).Count;
}
});
await RunConcurrently(writers, readers);
Assert.Equal(patches, s.Version);
Assert.Equal(patches, svc.GetHistory(s.Token).Count);
Assert.Equal(118m, s.Sums.AmountOpen);
}
}
+216
View File
@@ -0,0 +1,216 @@
using System;
using System.Collections.Generic;
using System.Data;
using System.Linq;
using Fuchs.Controllers;
using Fuchs.intranet;
using Microsoft.Extensions.Logging;
using Xunit;
using static fds.FdsMfrClient;
namespace Fuchs.Tests;
/// <summary>
/// Covers the error-attribution fixes from the 2026-09-16 production log review and the
/// subsequent migration onto the generic OCORE MultiLogger SQL log sink (ADR 0024):
/// <see cref="FIS_SQLOptions"/> now logs every SQL error through <see cref="ILogger"/> alone
/// (which the SQL sink additionally persists to <c>fds__debuglog</c> at Warning+, so the old,
/// separate <c>Fuchs_intranet.debug_log</c> DB write was retired to avoid double-logging), with
/// an <see cref="EventId"/> carrying the exact CodeReference text the retired DB write used to.
/// The DB write itself, and <c>IntranetController.Do</c>'s full catch-all, are SQL/DI-bound and
/// not unit-tested per repo convention; these tests pin the pure logic that feeds them.
/// </summary>
/// <remarks>
/// Shares <see cref="StaticOcoreLoggerCollection"/> with <c>FdsSqlOptionsTests</c>: both save/
/// mutate/restore the process-wide static <see cref="OCORE.Logging.Logger"/> field, which would
/// otherwise race across the two classes since xUnit parallelizes different test classes by
/// default.
/// </remarks>
[Collection(StaticOcoreLoggerCollection.Name)]
public class ErrorLoggingTests
{
/// <summary>Minimal <see cref="ILogger"/> double that records every call for assertion.</summary>
private sealed class RecordingLogger : ILogger
{
public sealed record Entry(LogLevel Level, EventId EventId, Exception? Exception, string Message,
IReadOnlyDictionary<string, object?> State);
public readonly List<Entry> Entries = new();
public IDisposable? BeginScope<TState>(TState state) where TState : notnull => null;
public bool IsEnabled(LogLevel logLevel) => true;
public void Log<TState>(LogLevel logLevel, EventId eventId, TState state, Exception? exception,
Func<TState, Exception?, string> formatter)
{
var pairs = (state as IEnumerable<KeyValuePair<string, object>>)?
.ToDictionary(kv => kv.Key, kv => (object?)kv.Value) ?? new Dictionary<string, object?>();
Entries.Add(new Entry(logLevel, eventId, exception, formatter(state, exception), pairs));
}
}
// ── FIS_SQLOptions ─────────────────────────────────────────────────────────
[Fact]
public void FisSqlOptions_OnError_LogsWithEventIdAndStructuredPlaceholders()
{
var logger = new RecordingLogger();
var context = new Dictionary<string, object> { ["fn"] = "inv", ["id"] = "invl", ["code"] = "" };
var opt = new FIS_SQLOptions(context, logger);
var ex = new TimeoutException("Das Ausführungstimeout ist abgelaufen.");
var sqlPayload = new { sql = "EXECUTE [dbo].[fds__getInvoiceList] @authuser;" };
opt.OnError!("getSQLDataSet", ex, sqlPayload);
var entry = Assert.Single(logger.Entries);
Assert.Equal(LogLevel.Error, entry.Level);
// EventId.Name preserves the exact CodeReference text the retired DB write used, so the SQL
// sink's fallback COALESCE(procedure, category, 'MultiLogger') keeps existing dashboards working.
Assert.Equal("SQL Error in getSQLDataSet", entry.EventId.Name);
Assert.Same(ex, entry.Exception);
// The failing statement and caller context are structured placeholders, not string-concatenated
// — this is what was missing before (every row's Data column was NULL).
Assert.Equal("getSQLDataSet", entry.State["Procedure"]);
Assert.Same(sqlPayload, entry.State["Sql"]);
Assert.Same(context, entry.State["Context"]);
// Plain Microsoft.Extensions.Logging (not Serilog) does not treat '@' as a destructuring
// operator; it would be kept as a literal leading character in the key. This asserts the
// placeholder names in FIS_SQLOptions are plain ("Sql"/"Context"), not "@Sql"/"@Context".
Assert.False(entry.State.ContainsKey("@Sql"));
Assert.Contains("getSQLDataSet", entry.Message);
}
[Fact]
public void FisSqlOptions_OnError_NoLoggerGiven_FallsBackToStaticOcoreLogger()
{
// Several call sites construct `new FIS_SQLOptions()` with neither logger nor context
// (Services/*.cs) — those must still reach a logger, not silently drop the error.
var fallback = new RecordingLogger();
var saved = OCORE.Logging.Logger;
OCORE.Logging.Logger = fallback;
try
{
var opt = new FIS_SQLOptions();
opt.OnError!("setSQLValue_async", new Exception("boom"), new { sql = "SELECT 1;" });
var entry = Assert.Single(fallback.Entries);
Assert.Equal("SQL Error in setSQLValue_async", entry.EventId.Name);
}
finally { OCORE.Logging.Logger = saved; }
}
[Fact]
public void FisSqlOptions_OnError_NoLoggerAndNoStaticFallback_DoesNotThrow()
{
var saved = OCORE.Logging.Logger;
OCORE.Logging.Logger = null;
try
{
var opt = new FIS_SQLOptions();
var recorded = Record.Exception(() =>
opt.OnError!("getSQLDataSet", new Exception("x"), new { sql = "SELECT 1;" }));
Assert.Null(recorded);
}
finally { OCORE.Logging.Logger = saved; }
}
// ── Response description header (500 → x-ocms-desc) ────────────────────────
[Theory]
[InlineData("Die PDF-Vorschau konnte nicht erstellt werden.", "Die%20PDF-Vorschau%20konnte%20nicht%20erstellt%20werden.")]
[InlineData("a+b", "a%2Bb")] // '+' must not survive: the client maps '+' to a space
[InlineData("Zeile1\r\nZeile2", "Zeile1%20%20Zeile2")]
[InlineData(" trimmed ", "trimmed")]
public void EncodeResponseDescription_ProducesAsciiHeaderValue_ClientDecodesToOriginal(string msg, string expected)
{
string encoded = IntranetController.EncodeResponseDescription(msg);
Assert.Equal(expected, encoded);
Assert.All(encoded, c => Assert.True(c < 128, "header must be ASCII"));
// Mirror of the client: decodeURIComponent(v.replace(/\+/g, '%20'))
Assert.Equal(msg.Replace("\r", " ").Replace("\n", " ").Trim(), Uri.UnescapeDataString(encoded.Replace("+", "%20")));
}
[Fact]
public void EncodeResponseDescription_UmlautsSurviveRoundTrip()
{
const string msg = "Zeitüberschreitung – bitte erneut versuchen";
string encoded = IntranetController.EncodeResponseDescription(msg);
Assert.All(encoded, c => Assert.True(c < 128));
Assert.Equal(msg, Uri.UnescapeDataString(encoded));
}
// ── PDF preview failure message ─────────────────────────────────────────────
[Fact]
public void PdfPreviewFailureMessage_UsesInnermostExceptionText()
{
var ex = new InvalidOperationException("outer",
new AggregateException("agg", new ApplicationException("Font 'Courier New' cannot be resolved")));
string msg = IntranetController.PdfPreviewFailureMessage(ex, includeDetail: true);
Assert.StartsWith("Die PDF-Vorschau konnte nicht erstellt werden: ", msg);
Assert.EndsWith("Font 'Courier New' cannot be resolved", msg);
Assert.DoesNotContain("outer", msg);
}
[Theory]
[InlineData("")]
[InlineData(" ")]
public void PdfPreviewFailureMessage_EmptyExceptionText_FallsBackToGenericText(string text)
{
string msg = IntranetController.PdfPreviewFailureMessage(new Exception(text), includeDetail: true);
Assert.Equal("Die PDF-Vorschau konnte nicht erstellt werden.", msg);
}
[Fact]
public void PdfPreviewFailureMessage_UnauthenticatedCaller_OmitsExceptionDetail()
{
var ex = new ApplicationException("Font 'Courier New' cannot be resolved");
string msg = IntranetController.PdfPreviewFailureMessage(ex, includeDetail: false);
Assert.Equal("Die PDF-Vorschau konnte nicht erstellt werden.", msg);
Assert.DoesNotContain("Courier", msg);
}
// ── Data service: schema failure must not cascade into "FROM [dbo].[]" ────
private static DataRowCollection NavTables(params string[] names)
{
var t = new DataTable();
t.Columns.Add("tablename", typeof(string));
foreach (var n in names) t.Rows.Add(n);
return t.Rows;
}
[Theory]
[InlineData("")]
[InlineData(" ")]
public void ShapeTableNames_NoEntityTable_ReturnsEmpty_EvenWithNavTables(string entityTable)
{
var names = DatabaseSchema.ShapeTableNames(entityTable, NavTables("mfr_invoice_items"));
Assert.Empty(names);
}
[Fact]
public void ShapeTableNames_EntityTableFirst_ThenNavTables_SkippingBlanksAndDuplicates()
{
var names = DatabaseSchema.ShapeTableNames("mfr_invoice", NavTables("mfr_invoice_items", "", "mfr_invoice_items", "mfr_invoice_files"));
Assert.Equal(new[] { "mfr_invoice", "mfr_invoice_items", "mfr_invoice_files" }, names);
}
[Fact]
public void ShapeTableNames_NoNavTables_ReturnsEntityOnly()
{
var names = DatabaseSchema.ShapeTableNames("mfr_invoice", NavTables());
Assert.Equal(new[] { "mfr_invoice" }, names);
}
}
+304
View File
@@ -0,0 +1,304 @@
using System;
using System.Collections.Generic;
using System.Data;
using System.IO;
using System.Linq;
using System.Text.RegularExpressions;
using fds;
using Xunit;
namespace Fuchs.Tests;
/// <summary>
/// mfr merge truncation (2026-09-30): mfr's free-text Note (ServiceObject up to 753 chars,
/// Company up to 723) overflowed NVARCHAR(255) in the mfr__ tables / mfr__tt__ types and every
/// batch holding such a row was rejected with the column-less message 8152. Covers the
/// failure-path diagnosis in <see cref="FdsColumnWidths"/> and guards the widened SSDT schema.
/// </summary>
public class FdsColumnWidthsTests
{
private const long LongNoteServiceObjectId = 38722404355;
// Staged batch shaped like "SELECT TOP(0) [setid] = …, * FROM mfr__serviceobjects".
private static DataTable ServiceObjectBatch(params (long Id, string? Note)[] rows)
{
var t = new DataTable("mfr__serviceobjects");
t.Columns.Add("setid", typeof(string));
t.Columns.Add("Id", typeof(long));
t.Columns.Add("Name", typeof(string));
t.Columns.Add("Note", typeof(string));
t.Columns.Add("CustomValues", typeof(string));
t.Columns.Add("Version", typeof(int));
foreach (var (id, note) in rows)
t.Rows.Add("abcde", id, "Heizung Keller", (object?)note ?? DBNull.Value, "[]", 1);
return t;
}
private static Dictionary<string, int> Widths(params (string Column, int Max)[] w) =>
w.ToDictionary(x => x.Column, x => x.Max, StringComparer.OrdinalIgnoreCase);
// ── IsTruncationError ────────────────────────────────────────────────────
[Theory]
[InlineData(8152, true)] // classic "String or binary data would be truncated."
[InlineData(2628, true)] // verbose variant (compat level 150+) that names the column
[InlineData(2627, false)] // PK violation
[InlineData(515, false)] // NULL into NOT NULL
[InlineData(8672, false)] // MERGE duplicate source row
[InlineData(0, false)]
[InlineData(-2, false)] // client timeout
public void IsTruncationError_ErrorNumber_OnlyTruncationNumbersMatch(int number, bool expected)
{
Assert.Equal(expected, FdsColumnWidths.IsTruncationError(number));
}
[Fact]
public void IsTruncationError_NonSqlOrNullException_ReturnsFalse()
{
Assert.False(FdsColumnWidths.IsTruncationError((Exception?)null));
Assert.False(FdsColumnWidths.IsTruncationError(new InvalidOperationException("String or binary data would be truncated.")));
}
// ── WidthsSql ────────────────────────────────────────────────────────────
[Fact]
public void WidthsSql_IsParameterised_AndCoversTargetTableAndItsTableType()
{
Assert.Contains("@tablename", FdsColumnWidths.WidthsSql);
Assert.Contains("REPLACE(@tablename, N'__', N'__tt__')", FdsColumnWidths.WidthsSql); // same naming as mfr__updt__ merge proc
Assert.Contains("sys.table_types", FdsColumnWidths.WidthsSql);
Assert.Contains("MIN(", FdsColumnWidths.WidthsSql); // narrower of table vs. type wins
}
// ── ToWidthMap ───────────────────────────────────────────────────────────
private static DataTable WidthRows(params (object Name, object Max)[] rows)
{
var t = new DataTable();
t.Columns.Add("columnname", typeof(object));
t.Columns.Add("maxchars", typeof(object));
foreach (var (n, m) in rows) t.Rows.Add(n, m);
return t;
}
[Fact]
public void ToWidthMap_ValidRows_MapsCaseInsensitively()
{
var map = FdsColumnWidths.ToWidthMap(WidthRows(("Note", 255), ("setid", (short)5)));
Assert.Equal(255, map["note"]);
Assert.Equal(5, map["SETID"]);
}
[Fact]
public void ToWidthMap_SameColumnTwice_KeepsNarrower()
{
var map = FdsColumnWidths.ToWidthMap(WidthRows(("Note", 500), ("note", 255)));
Assert.Equal(255, Assert.Single(map).Value);
}
[Fact]
public void ToWidthMap_InvalidRows_AreSkipped()
{
var map = FdsColumnWidths.ToWidthMap(WidthRows(
("", 10), (DBNull.Value, 10), ("A", DBNull.Value), ("B", 0), ("C", -1), ("Ok", 7)));
Assert.Equal(new[] { "Ok" }, map.Keys);
}
[Fact]
public void ToWidthMap_NullOrWrongShape_ReturnsEmpty()
{
Assert.Empty(FdsColumnWidths.ToWidthMap(null));
var wrong = new DataTable();
wrong.Columns.Add("name");
wrong.Rows.Add("Note");
Assert.Empty(FdsColumnWidths.ToWidthMap(wrong));
}
// ── FindOverflows ────────────────────────────────────────────────────────
[Theory]
[InlineData(0, false)]
[InlineData(254, false)]
[InlineData(255, false)] // exactly the limit fits
[InlineData(256, true)] // one over is rejected by SQL Server
[InlineData(753, true)] // the production ServiceObject note
public void FindOverflows_NoteLengthAroundLimit_ReportsOnlyWhenOver(int length, bool overflows)
{
var batch = ServiceObjectBatch((LongNoteServiceObjectId, new string('x', length)));
var result = FdsColumnWidths.FindOverflows(batch, Widths(("Note", 255), ("Name", 255), ("setid", 5)));
if (!overflows) { Assert.Empty(result); return; }
var o = Assert.Single(result);
Assert.Equal("Note", o.Column);
Assert.Equal(255, o.MaxAllowed);
Assert.Equal(length, o.LongestLength);
Assert.Equal(1, o.RowsOver);
Assert.Equal(LongNoteServiceObjectId, o.LongestRowId);
}
[Fact]
public void FindOverflows_SeveralRowsOver_CountsThemAndPointsAtTheLongest()
{
var batch = ServiceObjectBatch((1, new string('a', 300)), (2, "short"), (3, new string('b', 539)), (4, null));
var o = Assert.Single(FdsColumnWidths.FindOverflows(batch, Widths(("Note", 255))));
Assert.Equal(2, o.RowsOver);
Assert.Equal(539, o.LongestLength);
Assert.Equal(3L, o.LongestRowId);
}
[Fact]
public void FindOverflows_SeveralColumnsOver_ReportsEachColumn()
{
var batch = ServiceObjectBatch((7, new string('n', 300)));
batch.Rows[0]["setid"] = "toolong";
var cols = FdsColumnWidths.FindOverflows(batch, Widths(("Note", 255), ("setid", 5))).Select(o => o.Column);
Assert.Equal(new[] { "setid", "Note" }, cols); // DataTable column order
}
[Fact]
public void FindOverflows_MaxOrUnknownColumns_AreNeverReported()
{
// CustomValues is NVARCHAR(MAX) → WidthsSql omits it → no entry in the map.
var batch = ServiceObjectBatch((1, new string('x', 10_000)));
batch.Rows[0]["CustomValues"] = new string('c', 100_000);
Assert.Empty(FdsColumnWidths.FindOverflows(batch, Widths(("Name", 255))));
}
[Fact]
public void FindOverflows_NonStringColumnWithWidth_IsIgnored()
{
var batch = ServiceObjectBatch((123456789012, "x"));
Assert.Empty(FdsColumnWidths.FindOverflows(batch, Widths(("Id", 2), ("Version", 0))));
}
[Fact]
public void FindOverflows_EmptyBatchOrNoWidths_ReturnsEmpty()
{
Assert.Empty(FdsColumnWidths.FindOverflows(ServiceObjectBatch(), Widths(("Note", 1))));
Assert.Empty(FdsColumnWidths.FindOverflows(ServiceObjectBatch((1, new string('x', 999))), Widths()));
}
[Fact]
public void FindOverflows_TableWithoutIdColumn_ReportsWithoutRowId()
{
var t = new DataTable("mfr__*PartnerSet");
t.Columns.Add("Property", typeof(string));
t.Rows.Add(new string('p', 60));
var o = Assert.Single(FdsColumnWidths.FindOverflows(t, Widths(("property", 50))));
Assert.Null(o.LongestRowId);
Assert.Equal("Property: 60 > 50 chars (1 row)", o.ToString());
}
// ── Describe ─────────────────────────────────────────────────────────────
[Fact]
public void Describe_NoOverflow_SaysNoneDetected()
{
Assert.Equal("none detected", FdsColumnWidths.Describe(Array.Empty<FdsColumnWidths.Overflow>()));
}
[Fact]
public void Describe_Overflows_NameColumnLengthLimitAndId()
{
var text = FdsColumnWidths.Describe(new[]
{
new FdsColumnWidths.Overflow("Note", 255, 753, 1, LongNoteServiceObjectId),
new FdsColumnWidths.Overflow("Name", 255, 300, 3, 42L),
});
Assert.Equal("Note: 753 > 255 chars (1 row, e.g. Id=38722404355); Name: 300 > 255 chars (3 rows, e.g. Id=42)", text);
}
// ── Real SSDT schema: the widened Note must stay unbounded end to end ────
private static readonly Regex ColumnDecl = new(
@"\[(?<col>[^\]]+)\]\s+\[?n?varchar\]?\s*\(\s*(?<len>\d+|max)\s*\)",
RegexOptions.IgnoreCase | RegexOptions.Compiled);
private static string? FindDatabaseProject()
{
for (var dir = new DirectoryInfo(AppContext.BaseDirectory); dir != null; dir = dir.Parent)
{
string candidate = Path.Combine(dir.FullName, "Fuchs_Database", "dbo");
if (Directory.Exists(candidate)) return candidate;
}
return null;
}
/// <summary>Declared char widths of the bounded string columns in an SSDT object (MAX omitted, like WidthsSql).</summary>
private static Dictionary<string, int> DeclaredWidths(string relativePath, string? onlyWithin = null)
{
string? root = FindDatabaseProject();
if (root == null) Assert.Skip("Fuchs_Database SSDT project not present in this checkout.");
string sql = File.ReadAllText(Path.Combine(root!, relativePath));
if (onlyWithin != null)
{
int start = sql.IndexOf(onlyWithin, StringComparison.OrdinalIgnoreCase);
Assert.True(start >= 0, $"'{onlyWithin}' not found in {relativePath}");
sql = sql[start..sql.IndexOf(");", start, StringComparison.Ordinal)];
}
var map = new Dictionary<string, int>(StringComparer.OrdinalIgnoreCase);
foreach (Match m in ColumnDecl.Matches(sql))
if (!m.Groups["len"].Value.Equals("max", StringComparison.OrdinalIgnoreCase))
map[m.Groups["col"].Value] = int.Parse(m.Groups["len"].Value);
Assert.NotEmpty(map); // the regex still understands the file
return map;
}
public static IEnumerable<object[]> NoteCarriers() => new[]
{
new object[] { @"Tables\mfr__serviceobjects.sql", null! },
new object[] { @"User Defined Types\mfr__tt__serviceobjects.sql", null! },
new object[] { @"Tables\mfr__companies.sql", null! },
new object[] { @"User Defined Types\mfr__tt__companies.sql", null! },
// INSERT INTO @company SELECT cy.* FROM mfr__companies — must be as wide as the table.
new object[] { @"Stored Procedures\fds__getRequest_details.sql", "DECLARE @company TABLE" },
new object[] { @"Stored Procedures\fds__prepStorno_recreate.sql", "DECLARE @company TABLE" },
};
[Theory]
[MemberData(nameof(NoteCarriers))]
public void SsdtSchema_NoteColumn_IsUnbounded(string relativePath, string? onlyWithin)
{
var widths = DeclaredWidths(relativePath.Replace('\\', Path.DirectorySeparatorChar), onlyWithin);
Assert.False(widths.ContainsKey("Note"), $"{relativePath}: [Note] is bounded ({(widths.TryGetValue("Note", out int w) ? w : 0)}) — mfr notes exceed 255 chars");
}
[Theory]
[InlineData(@"Tables\mfr__serviceobjects.sql", @"User Defined Types\mfr__tt__serviceobjects.sql")]
[InlineData(@"Tables\mfr__companies.sql", @"User Defined Types\mfr__tt__companies.sql")]
public void SsdtSchema_ProductionLongNote_FitsTableAndTableType(string table, string type)
{
var widths = DeclaredWidths(table.Replace('\\', Path.DirectorySeparatorChar));
foreach (var kv in DeclaredWidths(type.Replace('\\', Path.DirectorySeparatorChar)))
widths[kv.Key] = widths.TryGetValue(kv.Key, out int w) ? Math.Min(w, kv.Value) : kv.Value;
var batch = ServiceObjectBatch((LongNoteServiceObjectId, new string('x', 753)));
Assert.Empty(FdsColumnWidths.FindOverflows(batch, widths));
}
[Fact]
public void SsdtSchema_OldNarrowNote_WouldHaveBeenReported()
{
// Failing-path counterpart: with the pre-fix NVARCHAR(255) the same batch is diagnosed.
var widths = DeclaredWidths(Path.Combine("User Defined Types", "mfr__tt__serviceobjects.sql"));
widths["Note"] = 255;
var batch = ServiceObjectBatch((LongNoteServiceObjectId, new string('x', 753)));
Assert.Equal("Note: 753 > 255 chars (1 row, e.g. Id=38722404355)",
FdsColumnWidths.Describe(FdsColumnWidths.FindOverflows(batch, widths)));
}
}
+98
View File
@@ -0,0 +1,98 @@
using System;
using System.Collections.Generic;
using System.Linq;
using fds;
using Microsoft.Extensions.Logging;
using Xunit;
namespace Fuchs.Tests;
/// <summary>
/// Covers <see cref="FdsSqlOptions"/> (the MFR-sync worker's equivalent of
/// <c>Fuchs.intranet.FIS_SQLOptions</c>): after ADR 0024, it must log a SQL error through
/// <see cref="ILogger"/> alone — the original 2026-09-16 log review's deadlock/timeout rows came
/// from this exact class calling both <see cref="ILogger"/> and a direct
/// <c>EXECUTE [dbo].[fds__admin_logdebug]</c>, which would double-write every row once the
/// worker's ILogger calls also reach the SQL log sink (hosted in the same process as the web app,
/// ADR 0010).
/// </summary>
/// <remarks>
/// Shares <see cref="StaticOcoreLoggerCollection"/> with <c>ErrorLoggingTests</c>: both save/
/// mutate/restore the process-wide static <see cref="OCORE.Logging.Logger"/> field, which would
/// otherwise race across the two classes since xUnit parallelizes different test classes by
/// default.
/// </remarks>
[Collection(StaticOcoreLoggerCollection.Name)]
public class FdsSqlOptionsTests
{
private sealed class RecordingLogger : ILogger
{
public sealed record Entry(LogLevel Level, EventId EventId, Exception? Exception, string Message,
IReadOnlyDictionary<string, object?> State);
public readonly List<Entry> Entries = new();
public IDisposable? BeginScope<TState>(TState state) where TState : notnull => null;
public bool IsEnabled(LogLevel logLevel) => true;
public void Log<TState>(LogLevel logLevel, EventId eventId, TState state, Exception? exception,
Func<TState, Exception?, string> formatter)
{
var pairs = (state as IEnumerable<KeyValuePair<string, object>>)?
.ToDictionary(kv => kv.Key, kv => (object?)kv.Value) ?? new Dictionary<string, object?>();
Entries.Add(new Entry(logLevel, eventId, exception, formatter(state, exception), pairs));
}
}
[Fact]
public void OnError_LogsOnce_WithProcedureAsEventIdName_NotPrefixed()
{
var logger = new RecordingLogger();
var dic = new Dictionary<string, object> { ["uid"] = "abc" };
var opt = new FdsSqlOptions(dic, logger);
var ex = new TimeoutException("deadlock victim");
opt.OnError!("mfr__getSchema", ex, new { sql = "EXECUTE [dbo].[mfr__getSchema] 'table', @tgttype;" });
var entry = Assert.Single(logger.Entries);
Assert.Equal(LogLevel.Error, entry.Level);
// Unprefixed: the direct DebugLog() call this replaced passed `procedure` as-is.
Assert.Equal("mfr__getSchema", entry.EventId.Name);
Assert.Same(ex, entry.Exception);
Assert.Equal("mfr__getSchema", entry.State["Procedure"]);
Assert.Same(dic, entry.State["CtrlInfo"]);
}
[Fact]
public void OnError_NoLoggerGiven_FallsBackToStaticOcoreLogger()
{
var fallback = new RecordingLogger();
var saved = OCORE.Logging.Logger;
OCORE.Logging.Logger = fallback;
try
{
var opt = new FdsSqlOptions();
opt.OnError!("fds__getUpdateableRequests", new Exception("boom"), new { sql = "SELECT 1;" });
var entry = Assert.Single(fallback.Entries);
Assert.Equal("fds__getUpdateableRequests", entry.EventId.Name);
}
finally { OCORE.Logging.Logger = saved; }
}
[Fact]
public void OnError_NoLoggerAndNoStaticFallback_DoesNotThrow()
{
var saved = OCORE.Logging.Logger;
OCORE.Logging.Logger = null;
try
{
var opt = new FdsSqlOptions();
var recorded = Record.Exception(() =>
opt.OnError!("mfr__getSchema", new Exception("x"), new { sql = "SELECT 1;" }));
Assert.Null(recorded);
}
finally { OCORE.Logging.Logger = saved; }
}
}
+27
View File
@@ -0,0 +1,27 @@
using System.Runtime.CompilerServices;
namespace Fuchs.Tests;
/// <summary>
/// Installs PdfSharp's global font resolver once, before any test runs.
/// </summary>
/// <remarks>
/// <see cref="PdfSharp.Fonts.GlobalFontSettings.FontResolver"/> is process-wide and, once used,
/// cannot be swapped — PdfSharp throws "You must not change font resolver after it was once used".
/// Two independent installers exist in this solution: <c>FuchsPdf.EnsureFontResolver</c> (installs
/// <c>OCOREFontResolver</c>, used by <see cref="PdfPipelineTests"/>) and eRechnungLib's
/// <c>SystemFontResolver.EnsureInstalled</c> (installs itself only when none is set yet, used
/// internally by the parameterless <c>ToZugferd(profile)</c> overload some tests call, e.g.
/// <see cref="ERechnungMapperTests"/>). Production code never hits that second path — it always
/// supplies its own rendered PDF (ADR 0012) — so this conflict is test-process-only, but whichever
/// installer's first render happened to run first (an xUnit test-execution-order accident, not a
/// property of the code under test) would win for every other test in the process. Installing
/// OCOREFontResolver here, before test discovery hands control to any test method, makes the
/// outcome deterministic instead of order-dependent.
/// </remarks>
internal static class FontResolverTestBootstrap
{
[ModuleInitializer]
internal static void Install() =>
PdfSharp.Fonts.GlobalFontSettings.FontResolver = new OCORE_web_pdf.pdf.OCOREFontResolver();
}
+4 -4
View File
@@ -9,14 +9,14 @@
</PropertyGroup> </PropertyGroup>
<ItemGroup> <ItemGroup>
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="18.9.0" /> <PackageReference Include="Microsoft.NET.Test.Sdk" Version="18.10.1" />
<PackageReference Include="xunit.v3" Version="4.0.0" /> <PackageReference Include="xunit.v3" Version="4.0.1" />
<PackageReference Include="xunit.runner.visualstudio" Version="4.0.0"> <PackageReference Include="xunit.runner.visualstudio" Version="4.0.0">
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets> <IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
<PrivateAssets>all</PrivateAssets> <PrivateAssets>all</PrivateAssets>
</PackageReference> </PackageReference>
<PackageReference Include="Moq" Version="4.20.72" /> <PackageReference Include="Moq" Version="4.21.0" />
<PackageReference Include="coverlet.collector" Version="10.0.1"> <PackageReference Include="coverlet.collector" Version="10.1.0">
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets> <IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
<PrivateAssets>all</PrivateAssets> <PrivateAssets>all</PrivateAssets>
</PackageReference> </PackageReference>
+132
View File
@@ -0,0 +1,132 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Net;
using System.Net.Http;
using System.Threading;
using System.Threading.Tasks;
using Fuchs.Services;
using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Extensions.Options;
using OCORE.web.pwmailer;
using Xunit;
namespace Fuchs.Tests;
/// <summary>
/// Covers the Fuchs override of <see cref="PWMailerClientService.ValidateMessage"/>: the Mailer
/// contract's attachment limits (count, per-file and per-message size, forbidden extensions) are
/// enforced before any HTTP request, on top of the base rules (required fields, empty file,
/// missing extension), and a compliant message still goes out. The transport is a scripted
/// handler that fails the test if it is reached when it must not be.
/// </summary>
public class FuchsMailerClientTests
{
private sealed class ScriptedHandler : HttpMessageHandler
{
public int Calls;
public string Body = "{\"comStackID\":\"11111111-2222-3333-4444-555555555555\",\"attachmentsReceived\":1,\"attachmentsProcessed\":1}";
protected override Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
{
Calls++;
return Task.FromResult(new HttpResponseMessage(HttpStatusCode.OK) { Content = new StringContent(Body) });
}
}
private sealed class Factory : IHttpClientFactory
{
private readonly HttpMessageHandler _handler;
public Factory(HttpMessageHandler handler) => _handler = handler;
public HttpClient CreateClient(string name) => new(_handler, disposeHandler: false);
}
private static (FuchsMailerClient client, ScriptedHandler handler) Make(MailerAttachmentLimits? limits = null)
{
var handler = new ScriptedHandler();
var options = Options.Create(new PWMailerClientOptions
{
BaseUrl = "https://mailer.test/api/mailer", Account = "acct", Token = "tok",
ServerId = "11111111-2222-3333-4444-555555555555", RetryDelayMilliseconds = 0
});
var settings = Options.Create(new ProcessWebComSettings { Attachments = limits ?? new MailerAttachmentLimits() });
return (new FuchsMailerClient(options, settings, new Factory(handler), NullLogger<PWMailerClientService>.Instance), handler);
}
private static PWMailerMessage Message(params PWMailerAttachment[] attachments)
{
var m = new PWMailerMessage { To = "a@example.com", Subject = "Hi", Body = "<p>x</p>", Reference = "R1" };
m.Attachments.AddRange(attachments);
return m;
}
public static IEnumerable<object[]> LimitViolations()
{
yield return [new PWMailerAttachment[] { new("run.EXE", [1]) }, "forbidden extension (.exe)"];
yield return [new PWMailerAttachment[] { new("script.js", [1]) }, "forbidden extension (.js)"];
yield return [Enumerable.Range(1, 11).Select(i => new PWMailerAttachment($"f{i}.pdf", [1])).ToArray(), "Too many attachments: 11 (max 10)"];
}
[Theory]
[MemberData(nameof(LimitViolations))]
public async Task PushAsync_AttachmentViolatesContractLimits_RefusedBeforeSending(PWMailerAttachment[] attachments, string expected)
{
var (client, handler) = Make();
var result = await client.PushAsync(Message(attachments));
Assert.False(result.Success);
Assert.Equal(0, result.StatusCode);
Assert.Equal(0, result.Attempts);
Assert.Contains(expected, result.Error);
Assert.Equal(0, handler.Calls);
}
[Fact]
public async Task PushAsync_PerFileAndPerMessageSizeLimits_AreEnforcedFromSettings()
{
var (client, handler) = Make(new MailerAttachmentLimits { MaxFileBytes = 100, MaxMessageBytes = 150, MaxCount = 10 });
var tooBig = await client.PushAsync(Message(new PWMailerAttachment("a.pdf", new byte[101])));
Assert.Contains("'a.pdf' is 101 bytes (max 100)", tooBig.Error);
var total = await client.PushAsync(Message(new PWMailerAttachment("a.pdf", new byte[100]), new PWMailerAttachment("b.pdf", new byte[51])));
Assert.Contains("total 151 bytes (max 150", total.Error);
Assert.Equal(0, handler.Calls);
}
[Theory]
[InlineData("leer.pdf", new byte[0], "is empty")]
[InlineData("noext", new byte[] { 1 }, "no file extension")]
public async Task PushAsync_BaseContractRules_StillAppliedThroughOverride(string name, byte[] bytes, string expected)
{
var (client, handler) = Make();
var result = await client.PushAsync(Message(new PWMailerAttachment(name, bytes)));
Assert.False(result.Success);
Assert.Contains(expected, result.Error);
Assert.Equal(0, handler.Calls);
}
[Theory]
[InlineData("Rechnung.pdf")]
[InlineData("export.CSV")]
[InlineData("archive.7z")]
public async Task PushAsync_AllowedAttachment_IsSent(string name)
{
var (client, handler) = Make();
var result = await client.PushAsync(Message(new PWMailerAttachment(name, [1, 2])));
Assert.True(result.Success, result.Error);
Assert.Equal(1, handler.Calls);
Assert.Equal(1, result.Value!.AttachmentsProcessed);
}
[Fact]
public void MailerAttachmentPolicy_NoAttachments_NoViolation()
{
Assert.Empty(MailerAttachmentPolicy.Validate(Array.Empty<PWMailerAttachment>(), new MailerAttachmentLimits()));
}
}
+165
View File
@@ -0,0 +1,165 @@
using System;
using System.Collections.Generic;
using System.Data;
using System.Globalization;
using System.Linq;
using Fuchs.intranet;
using Microsoft.Data.SqlClient;
using Newtonsoft.Json.Linq;
using Xunit;
namespace Fuchs.Tests;
/// <summary>
/// Invoice amounts reach the procs as exact, cent-rounded decimals, independent of the server
/// culture. Regression for the Stornorechnung save failure: the VAT amount was formatted
/// invariantly ("-615.3757999999999", unrounded double from a reloaded Storno line) and re-parsed
/// under de-DE as -6153757999999999 → "Error converting data type float to numeric".
/// </summary>
public class InvoiceAmountParamTests : IDisposable
{
private readonly CultureInfo _culture = CultureInfo.CurrentCulture;
public void Dispose() => CultureInfo.CurrentCulture = _culture;
private static readonly string[] AmountParams =
{ "@InvoiceBalance", "@InvoiceBalance_net", "@InvoiceVAT_net1", "@InvoiceService_net", "@InvoiceService_VAT" };
/// <summary>The exact shape InvoiceDraftEditService.BuildFdsData produces: decimal sums, VAT map by rate.</summary>
private static FdsInvoiceData StornoDraft(object vatAmount) => new(new JObject
{
["admin"] = new JObject { ["type"] = "c", ["customerid"] = "7" },
["new"] = new JObject { ["title"] = "Stornorechnung", ["total_net"] = -3238.82m, ["total_gross"] = -3854.20m },
["sms"] = new JObject
{
["tscn"] = -1234.56m, ["tscvat"] = -234.57m,
["vat"] = new JObject { ["19"] = JToken.FromObject(vatAmount) }
}
});
private static decimal Val(List<SqlParameter> pl, string name)
{
var p = pl.Single(x => x.ParameterName == name);
Assert.Equal(SqlDbType.Decimal, p.SqlDbType);
return Assert.IsType<decimal>(p.Value);
}
public static TheoryData<string, object> CulturesAndVat => new()
{
{ "de-DE", -615.38m },
{ "en-US", -615.38m },
{ "de-DE", -615.3757999999999 }, // unrounded double from a reloaded Storno line (net * vat * 0.01)
{ "en-US", -615.3757999999999 },
{ "de-DE", -615.3800000000001 },
{ "", -615.38m }, // invariant
};
[Theory]
[MemberData(nameof(CulturesAndVat))]
public void BuildInvoiceParams_StornoDraft_AmountsExactInEveryCulture(string culture, object vatAmount)
{
CultureInfo.CurrentCulture = new CultureInfo(culture);
var pl = StornoDraft(vatAmount).BuildInvoiceParams(change: true, invId: "abc");
Assert.Equal(-3854.20m, Val(pl, "@InvoiceBalance"));
Assert.Equal(-3238.82m, Val(pl, "@InvoiceBalance_net"));
Assert.Equal(-615.38m, Val(pl, "@InvoiceVAT_net1"));
Assert.Equal(-1234.56m, Val(pl, "@InvoiceService_net"));
Assert.Equal(-234.57m, Val(pl, "@InvoiceService_VAT"));
}
[Fact]
public void BuildInvoiceParams_AmountParams_MatchProcPrecisionAndScale()
{
var pl = StornoDraft(-615.38m).BuildInvoiceParams(change: false, invId: "");
foreach (var name in AmountParams)
{
var p = pl.Single(x => x.ParameterName == name);
Assert.Equal(10, p.Precision);
// fds__setInvoice/fds__createInvoice: numeric(10,3); fds__createInvoice_Details: numeric(10,2).
Assert.Equal(name.StartsWith("@InvoiceService_") ? 2 : 3, p.Scale);
}
}
[Theory]
[InlineData(-10_000_000.00)]
[InlineData(10_000_000.00)]
[InlineData(-6153757999999999.0)]
public void BuildInvoiceParams_AmountOutOfProcRange_Throws(double vat)
{
var ex = Assert.Throws<InvoiceAmountOutOfRangeException>(() =>
StornoDraft(vat).BuildInvoiceParams(change: true, invId: "abc"));
Assert.Equal("@InvoiceVAT_net1", ex.Parameter);
}
[Theory]
[InlineData(9_999_999.99)]
[InlineData(-9_999_999.99)]
[InlineData(0.0)]
public void BuildInvoiceParams_AmountAtProcRangeBoundary_Accepted(double vat)
{
var pl = StornoDraft(vat).BuildInvoiceParams(change: true, invId: "abc");
Assert.Equal((decimal)vat, Val(pl, "@InvoiceVAT_net1"));
}
[Fact]
public void BuildInvoiceParams_MissingAmounts_KeepPreviousNullAndZeroSemantics()
{
var noSms = new FdsInvoiceData(new JObject { ["admin"] = new JObject(), ["new"] = new JObject() })
.BuildInvoiceParams(change: false, invId: "");
Assert.Equal(DBNull.Value, noSms.Single(p => p.ParameterName == "@InvoiceBalance").Value);
Assert.Equal(0m, noSms.Single(p => p.ParameterName == "@InvoiceService_net").Value); // no sms block → 0
Assert.Equal(0m, noSms.Single(p => p.ParameterName == "@InvoiceVAT_net1").Value); // HighestVat → "0"
var emptySms = new FdsInvoiceData(new JObject { ["new"] = new JObject(), ["sms"] = new JObject() })
.BuildInvoiceParams(change: false, invId: "");
Assert.Equal(DBNull.Value, emptySms.Single(p => p.ParameterName == "@InvoiceService_net").Value); // key missing → NULL
// No "new" block at all: the legacy SQL_Float(nz(...) ?? "0") sent 0, not NULL.
var noNew = new FdsInvoiceData(new JObject { ["admin"] = new JObject(), ["sms"] = new JObject() })
.BuildInvoiceParams(change: false, invId: "");
Assert.Equal(0m, noNew.Single(p => p.ParameterName == "@InvoiceBalance").Value);
Assert.Equal(0m, noNew.Single(p => p.ParameterName == "@InvoiceBalance_net").Value);
}
[Theory]
[InlineData("-19.95", "-19.95")]
[InlineData("-19,95", "-19.95")]
[InlineData("-1234.5600000000002", "-1234.56")]
[InlineData("-615.3757999999999", "-615.38")]
[InlineData("1234,565", "1234.57")] // rounded half away from zero
[InlineData("-1234,565", "-1234.57")]
[InlineData("-1.5E-05", "0")]
[InlineData(" 42 ", "42")]
public void ParseAmount_Strings_ParsedCultureIndependentlyAndRoundedToCents(string raw, string expected)
{
foreach (var c in new[] { "de-DE", "en-US" })
{
CultureInfo.CurrentCulture = new CultureInfo(c);
Assert.Equal(decimal.Parse(expected, CultureInfo.InvariantCulture), FdsInvoiceData.ParseAmount(raw));
}
}
[Theory]
[InlineData(null)]
[InlineData("")]
[InlineData("NaN")]
[InlineData("abc")]
[InlineData(double.NaN)]
[InlineData(double.PositiveInfinity)]
public void ParseAmount_EmptyOrInvalid_IsNull(object? raw) =>
Assert.Null(FdsInvoiceData.ParseAmount(raw));
[Fact]
public void ParseAmount_NumbersAndJValues_TakenAsIs()
{
Assert.Equal(-3238.82m, FdsInvoiceData.ParseAmount(-3238.82m));
Assert.Equal(-3238.82m, FdsInvoiceData.ParseAmount(-3238.82));
Assert.Equal(-12m, FdsInvoiceData.ParseAmount(-12));
Assert.Equal(-3238.82m, FdsInvoiceData.ParseAmount(new JValue(-3238.82m)));
Assert.Null(FdsInvoiceData.ParseAmount(JValue.CreateNull()));
}
}
+105
View File
@@ -0,0 +1,105 @@
using System;
using System.Collections.Generic;
using Fuchs.intranet;
using Xunit;
using static OCORE.OCORE_dictionaries;
namespace Fuchs.Tests;
/// <summary>
/// ADR 0032: one invoice date for the PDF ("Rechnungsdatum") and the e-invoice issue date —
/// DateFinalized (incl. backdated), else DateCreated — converted from the stored UTC to the German
/// calendar day; plus the backdate request/response parsing used by inv/dpreview and req/sconf.
/// </summary>
public class InvoiceDatesTests
{
private static GenericObjectDictionary Reg(object? finalized, object? created) => new(new Dictionary<string, object?>
{
["DateFinalized"] = finalized,
["DateCreated"] = created
});
[Fact]
public void IssueDate_FinalizedAndCreated_PrefersDateFinalized()
=> Assert.Equal(new DateOnly(2026, 9, 30),
InvoiceDates.IssueDate(Reg(new DateTime(2026, 9, 30, 12, 0, 0), new DateTime(2026, 9, 2, 8, 0, 0))));
[Fact]
public void IssueDate_DraftWithoutFinalized_FallsBackToDateCreated()
=> Assert.Equal(new DateOnly(2026, 9, 2),
InvoiceDates.IssueDate(Reg(DBNull.Value, new DateTime(2026, 9, 2, 8, 0, 0))));
[Fact]
public void IssueDate_NoRegistration_UsesProvidedUtcNow()
=> Assert.Equal(new DateOnly(2026, 3, 1),
InvoiceDates.IssueDate(null, new DateTime(2026, 3, 1, 10, 0, 0, DateTimeKind.Utc)));
[Theory]
// UTC late evening is already the next German day (summer +2h, winter +1h)
[InlineData("2026-09-30T22:30:00", "2026-10-01")]
[InlineData("2026-09-30T21:59:00", "2026-09-30")]
[InlineData("2026-01-31T23:30:00", "2026-02-01")]
[InlineData("2026-01-31T22:59:00", "2026-01-31")]
// backdated invoices are stored at 12:00 UTC and stay on their day
[InlineData("2026-08-31T12:00:00", "2026-08-31")]
[InlineData("2025-12-31T12:00:00", "2025-12-31")]
public void IssueDate_StoredUtc_ReturnsGermanCalendarDay(string storedUtc, string expected)
=> Assert.Equal(DateOnly.Parse(expected),
InvoiceDates.IssueDate(Reg(DateTime.Parse(storedUtc), null)));
[Theory]
[InlineData("2026-09-30", 2026, 9, 30)]
[InlineData(" 2025-12-31 ", 2025, 12, 31)]
public void ParseBackdate_IsoDate_ReturnsDate(string iso, int y, int m, int d)
=> Assert.Equal(new DateOnly(y, m, d), InvoiceDates.ParseBackdate(iso));
[Theory]
[InlineData(null)]
[InlineData("")]
[InlineData("NULL")]
[InlineData("30.09.2026")]
[InlineData("2026-02-30")]
public void ParseBackdate_EmptyOrInvalid_ReturnsNull(string? iso)
=> Assert.Null(InvoiceDates.ParseBackdate(iso));
[Theory]
[InlineData("1", true)]
[InlineData("true", true)]
[InlineData("TRUE", true)]
[InlineData("on", true)]
[InlineData("0", false)]
[InlineData("false", false)]
[InlineData("", false)]
[InlineData(null, false)]
[InlineData("yes please", false)]
public void IsBackdateRequested_FormValue_ParsesStrictly(string? value, bool expected)
=> Assert.Equal(expected, InvoiceDates.IsBackdateRequested(value));
// fds__setInvoiceFinal result → what req/sconf tells the user. Only 60001 is a backdate rejection;
// a 0-row result is the already-final/sent guard (double submit) and must not invite a duplicate.
[Theory]
[InlineData("", null, 1, InvoiceFinalizeOutcome.Finalized)]
[InlineData(null, null, 1, InvoiceFinalizeOutcome.Finalized)]
[InlineData("SQL_exception: backdating not allowed", 60001, 0, InvoiceFinalizeOutcome.BackdateNotAllowed)]
[InlineData("SQL_exception: not authorized", 60000, 0, InvoiceFinalizeOutcome.Error)]
[InlineData("SQL_exception: lock not acquired", 60002, 0, InvoiceFinalizeOutcome.Error)]
[InlineData("SQL_exception: Cannot insert NULL", 515, 0, InvoiceFinalizeOutcome.Error)]
[InlineData("SQL_exception: timeout", null, 0, InvoiceFinalizeOutcome.Error)]
[InlineData("", null, 0, InvoiceFinalizeOutcome.AlreadyFinal)]
[InlineData(null, null, 0, InvoiceFinalizeOutcome.AlreadyFinal)]
public void ClassifyFinalizeResult_SqlOutcome_MapsToOutcome(string? exception, int? errorNumber, int rows, InvoiceFinalizeOutcome expected)
=> Assert.Equal(expected, InvoiceDates.ClassifyFinalizeResult(exception, errorNumber, rows));
[Fact]
public void ClassifyFinalizeResult_ErrorNumber60001WithoutExceptionText_IsNotARejection()
// the number alone never arrives without a message; a row with no exception is a success
=> Assert.Equal(InvoiceFinalizeOutcome.Finalized, InvoiceDates.ClassifyFinalizeResult("", 60001, 1));
[Theory]
[InlineData(InvoiceFinalizeOutcome.Finalized, "ok")]
[InlineData(InvoiceFinalizeOutcome.BackdateNotAllowed, "rejected")]
[InlineData(InvoiceFinalizeOutcome.AlreadyFinal, "already_final")]
[InlineData(InvoiceFinalizeOutcome.Error, "error")]
public void OutcomeTag_EveryOutcome_HasDistinctTelemetryTag(InvoiceFinalizeOutcome outcome, string expected)
=> Assert.Equal(expected, InvoiceDates.OutcomeTag(outcome));
}
+166 -53
View File
@@ -34,7 +34,7 @@ public class InvoiceDraftServiceTests
public FdsInvoiceData? PreviewInvoice; public FdsInvoiceData? PreviewInvoice;
public bool? PreviewDraft; public bool? PreviewDraft;
public Task<FdsInvoiceData> LoadInvoiceAsync(string id, string u, DatabaseSecurity s) => throw new System.NotSupportedException(); public Task<FdsInvoiceData> LoadInvoiceAsync(string id, string u, DatabaseSecurity s) => throw new System.NotSupportedException();
public Document GenerateInvoicePdf(FdsInvoiceData i, bool d) { PreviewInvoice = i; PreviewDraft = d; return new Document(); } public Task<Document> GenerateInvoicePdfAsync(FdsInvoiceData i, bool d) { PreviewInvoice = i; PreviewDraft = d; return Task.FromResult(new Document()); }
public Task<byte[]> RenderInvoicePdfBytesAsync(FdsInvoiceData i, bool d) => throw new System.NotSupportedException(); public Task<byte[]> RenderInvoicePdfBytesAsync(FdsInvoiceData i, bool d) => throw new System.NotSupportedException();
public Task<byte[]> StoreInvoiceDocumentFileAsync(FdsInvoiceData i, bool d, string u, DatabaseSecurity s) => throw new System.NotSupportedException(); public Task<byte[]> StoreInvoiceDocumentFileAsync(FdsInvoiceData i, bool d, string u, DatabaseSecurity s) => throw new System.NotSupportedException();
public Task<byte[]?> GetInvoiceFileAsync(FdsInvoiceData i, bool d, fds.IFdsMfr m) => throw new System.NotSupportedException(); public Task<byte[]?> GetInvoiceFileAsync(FdsInvoiceData i, bool d, fds.IFdsMfr m) => throw new System.NotSupportedException();
@@ -169,7 +169,8 @@ public class InvoiceDraftServiceTests
var result = await svc.FlushToDbAsync(s.Token, "user1", null!); var result = await svc.FlushToDbAsync(s.Token, "user1", null!);
Assert.NotNull(result); Assert.NotNull(result);
Assert.Equal("INV42", result!.Id); Assert.Equal("INV42", result!.Data.Id);
Assert.False(result.Updated);
Assert.False(inv.LastChange); // new draft (no prior InvId) → create, not update Assert.False(inv.LastChange); // new draft (no prior InvId) → create, not update
Assert.Equal("INV42", svc.Get(s.Token)!.InvId); Assert.Equal("INV42", svc.Get(s.Token)!.InvId);
@@ -413,105 +414,216 @@ public class InvoiceDraftServiceTests
Assert.Equal(125m, s2.Sums.TotalNet); Assert.Equal(125m, s2.Sums.TotalNet);
} }
// ── Block set-price menu modes (ADR 0009): per-service-request-block, irreversible ── // ── Set-price menu modes (ADR 0019): keyed on the SET, never on the block ──
private static JObject BlocksPayload() => JObject.Parse(@"{ // Two service-request blocks, each with one set, plus a standalone item outside any set:
// block 1: set '10' { '11' 60 net, '12' 40 net (service) } + '13' 25 net, no set
// block 2: set '20' { '21' 30 net }
// Expected for both modes: every set gets the sum of its own positions, several sets stay
// several separate lines, and '13' keeps its individual price and stays listed.
private static JObject SetsAndLooseItemsPayload() => JObject.Parse(@"{
'admin':{'p13b':false,'type':'r'}, 'admin':{'p13b':false,'type':'r'},
'new':{'invoiceemail':'a@b.de','invoiceaddress':'Weg 1','invoicetitle':'Rechnung'}, 'new':{'invoiceemail':'a@b.de','invoiceaddress':'Weg 1','invoicetitle':'Rechnung'},
'req':[ 'req':[
{'Id':'1','text':'Auftrag A','itm':[ {'Id':'1','text':'Auftrag A','itm':[
{'id':'11','typ':'material','vt':60,'vv':11.4,'vs':0,'vsv':0,'vat':'19%'}, {'id':'10','typ':'set','vt':0,'vv':0,'vs':0,'vsv':0,'vat':'19%','SetItmId':'10'},
{'id':'12','typ':'service','vt':40,'vv':7.6,'vs':40,'vsv':7.6,'vat':'19%'}], {'id':'11','typ':'material','vt':60,'vv':11.4,'vs':0,'vsv':0,'vat':'19%','SetItmId':'10'},
'items':[{'id':'11','type':'material','total_net':60,'vat':'19%'}, {'id':'12','typ':'service','vt':40,'vv':7.6,'vs':40,'vsv':7.6,'vat':'19%','SetItmId':'10'},
{'id':'12','type':'service','total_net':40,'vat':'19%'}]}, {'id':'13','typ':'material','vt':25,'vv':4.75,'vs':0,'vsv':0,'vat':'19%','SetItmId':null}],
'items':[{'id':'10','type':'set','total_net':0},
{'id':'11','type':'material','total_net':60,'vat':'19%'},
{'id':'12','type':'service','total_net':40,'vat':'19%'},
{'id':'13','type':'material','total_net':25,'vat':'19%'}]},
{'Id':'2','text':'Auftrag B','itm':[ {'Id':'2','text':'Auftrag B','itm':[
{'id':'21','typ':'material','vt':30,'vv':5.7,'vs':0,'vsv':0,'vat':'19%'}], {'id':'20','typ':'set','vt':0,'vv':0,'vs':0,'vsv':0,'vat':'19%','SetItmId':'20'},
'items':[{'id':'21','type':'material','total_net':30,'vat':'19%'}]}] {'id':'21','typ':'material','vt':30,'vv':5.7,'vs':0,'vsv':0,'vat':'19%','SetItmId':'20'}],
'items':[{'id':'20','type':'set','total_net':0},
{'id':'21','type':'material','total_net':30,'vat':'19%'}]}]
}"); }");
[Fact] [Fact]
public void ApplyPatch_BlockSetPrice_InsertsSetRowPerBlock_NullsMembers_TotalUnchanged() public void ApplyPatch_SetPrice_PricesEverySet_BlanksItsMembers_LeavesNonSetItemsPriced()
{ {
var (svc, _, _) = NewService(); var (svc, _, _) = NewService();
var s = svc.OpenFromPayload(BlocksPayload(), "user1"); var s = svc.OpenFromPayload(SetsAndLooseItemsPayload(), "user1");
Assert.Equal(130m, s.Sums.TotalNet); // 100 (A) + 30 (B) Assert.Equal(155m, s.Sums.TotalNet); // 100 (set A) + 25 (loose) + 30 (set B)
var s2 = svc.ApplyPatch(s.Token, new InvoiceDraftDelta { Target = "block.setprice" }); var s2 = svc.ApplyPatch(s.Token, new InvoiceDraftDelta { Target = "block.setprice" });
Assert.NotNull(s2); Assert.NotNull(s2);
Assert.Equal(1, s2!.Version); Assert.Equal(1, s2!.Version);
var linesA = (JArray)((JObject)s2.Req[0])["itm"]!; // No row is inserted or dropped — the existing set line carries the price now.
Assert.Equal(3, linesA.Count); // set row + the two (nulled) members, kept Assert.Equal(new[] { "10", "11", "12", "13" }, LineOrder(s2));
var setA = (JObject)linesA[0]; var setA = LineOf(s2, "10");
Assert.Equal("set", (string)setA["typ"]!); Assert.Equal(100m, setA["vt"]!.Value<decimal>()); // its own members only (60 + 40), NOT the block (125)
Assert.Equal("bset_1", (string)setA["id"]!);
Assert.Equal(100m, setA["vt"]!.Value<decimal>()); // block sum
Assert.Equal(19m, setA["vv"]!.Value<decimal>()); Assert.Equal(19m, setA["vv"]!.Value<decimal>());
Assert.Equal(40m, setA["vs"]!.Value<decimal>()); // service-net split preserved Assert.Equal(40m, setA["vs"]!.Value<decimal>()); // service-net split preserved
Assert.Equal(7.6m, setA["vsv"]!.Value<decimal>()); Assert.Equal(7.6m, setA["vsv"]!.Value<decimal>());
var m11 = linesA.OfType<JObject>().Single(l => (string)l["id"]! == "11"); Assert.Equal(JTokenType.Null, LineOf(s2, "11")["vt"]!.Type); // member: null (empty cell), not 0
Assert.Equal(JTokenType.Null, m11["vt"]!.Type); // ADR 0009: null (empty cell), not 0 Assert.Equal(JTokenType.Null, LineOf(s2, "12")["vt"]!.Type);
Assert.Equal(JTokenType.Null, m11["vv"]!.Type);
// items contract mirrors it (PDF reads total_net from here). // The item outside the set keeps its own price and is still listed individually.
Assert.Equal(25m, LineOf(s2, "13")["vt"]!.Value<decimal>());
Assert.Equal(4.75m, LineOf(s2, "13")["vv"]!.Value<decimal>());
// The second set is a separate priced line, not merged with the first.
Assert.Equal(30m, LineOf(s2, "20", block: 1)["vt"]!.Value<decimal>());
Assert.Equal(JTokenType.Null, LineOf(s2, "21", block: 1)["vt"]!.Type);
// items contract mirrors it (the PDF reads total_net from here).
var itemsA = (JArray)((JObject)s2.Req[0])["items"]!; var itemsA = (JArray)((JObject)s2.Req[0])["items"]!;
Assert.Equal("set", (string)((JObject)itemsA[0])["type"]!); Assert.Equal(100m, itemsA.OfType<JObject>().Single(i => (string)i["id"]! == "10")["total_net"]!.Value<decimal>());
Assert.Equal(100m, ((JObject)itemsA[0])["total_net"]!.Value<decimal>()); Assert.Equal(100m, itemsA.OfType<JObject>().Single(i => (string)i["id"]! == "10")["price_net"]!.Value<decimal>()); // unit price too (flat PDF path)
Assert.Equal(JTokenType.Null, itemsA.OfType<JObject>().Single(i => (string)i["id"]! == "11")["total_net"]!.Type); Assert.Equal(JTokenType.Null, itemsA.OfType<JObject>().Single(i => (string)i["id"]! == "11")["total_net"]!.Type);
Assert.Equal(25m, itemsA.OfType<JObject>().Single(i => (string)i["id"]! == "13")["total_net"]!.Value<decimal>());
// second block converted too; total conserved. // Sets + loose items make up the unchanged invoice total.
Assert.Equal(30m, ((JObject)((JArray)((JObject)s2.Req[1])["itm"]!)[0])["vt"]!.Value<decimal>()); Assert.Equal(155m, s2.Sums.TotalNet);
Assert.Equal(130m, s2.Sums.TotalNet); Assert.Equal(29.45m, s2.Sums.TotalVat);
Assert.Equal(24.7m, s2.Sums.TotalVat);
} }
[Fact] [Fact]
public void ApplyPatch_BlockSetOnly_InsertsSetRowPerBlock_RemovesMembers_TotalUnchanged() public void ApplyPatch_SetOnly_PricesEverySet_RemovesItsMembers_KeepsNonSetItems()
{ {
var (svc, _, _) = NewService(); var (svc, _, _) = NewService();
var s = svc.OpenFromPayload(BlocksPayload(), "user1"); var s = svc.OpenFromPayload(SetsAndLooseItemsPayload(), "user1");
var s2 = svc.ApplyPatch(s.Token, new InvoiceDraftDelta { Target = "block.setonly" }); var s2 = svc.ApplyPatch(s.Token, new InvoiceDraftDelta { Target = "block.setonly" });
Assert.NotNull(s2); Assert.NotNull(s2);
var linesA = (JArray)((JObject)s2!.Req[0])["itm"]!; // Set members gone; the set line and the item that belongs to no set remain.
Assert.Single(linesA); // members removed, only the set row remains Assert.Equal(new[] { "10", "13" }, LineOrder(s2!));
Assert.Equal("set", (string)((JObject)linesA[0])["typ"]!); Assert.Equal(100m, LineOf(s2!, "10")["vt"]!.Value<decimal>());
Assert.Equal(100m, ((JObject)linesA[0])["vt"]!.Value<decimal>()); Assert.Equal(25m, LineOf(s2!, "13")["vt"]!.Value<decimal>());
Assert.Single((JArray)((JObject)s2.Req[0])["items"]!); Assert.Equal(new[] { "10", "13" },
Assert.Equal(130m, s2.Sums.TotalNet); // total unchanged ((JArray)((JObject)s2!.Req[0])["items"]!).OfType<JObject>().Select(i => (string)i["id"]!).ToArray());
// The second set stays a separate line of its own.
Assert.Equal(new[] { "20" }, LineOrder(s2, block: 1));
Assert.Equal(30m, LineOf(s2, "20", block: 1)["vt"]!.Value<decimal>());
Assert.Equal(155m, s2.Sums.TotalNet); // total unchanged
} }
[Fact] [Fact]
public void ApplyPatch_BlockSetPrice_SetRowNotSweptIntoBuildSetDisplay() public void ApplyPatch_SetOnly_AlreadyConvertedSet_KeepsItsPrice_AndStillRemovesMembers()
{ {
// The block set row has no setId members -> HasSetMembers is false -> it renders flat
// (emphasised on its own, members null to blank), so BuildSetDisplay must not emit flags for it.
var (svc, _, _) = NewService(); var (svc, _, _) = NewService();
var s = svc.OpenFromPayload(BlocksPayload(), "user1"); var s = svc.OpenFromPayload(SetsAndLooseItemsPayload(), "user1");
var conv = svc.ApplyPatch(s.Token, new InvoiceDraftDelta { Target = "item.setprice", Ref = "10" }); // inline switch first
Assert.Equal(100m, LineOf(conv!, "10")["vt"]!.Value<decimal>());
// A repeated conversion is a no-op — it must never re-sum the already-nulled members to 0.
var again = svc.ApplyPatch(s.Token, new InvoiceDraftDelta { Target = "item.setprice", Ref = "10" });
Assert.Equal(1, again!.Version);
Assert.Equal(100m, LineOf(again, "10")["vt"]!.Value<decimal>());
var s2 = svc.ApplyPatch(s.Token, new InvoiceDraftDelta { Target = "block.setonly" });
Assert.NotNull(s2);
Assert.Equal(new[] { "10", "13" }, LineOrder(s2!));
Assert.Equal(100m, LineOf(s2!, "10")["vt"]!.Value<decimal>()); // not re-summed to 0 from nulled members
Assert.Equal(155m, s2!.Sums.TotalNet);
}
[Fact]
public void ApplyPatch_SetPrice_NestedSets_OnlyTheTopLevelSetIsPriced_TotalNotDoubled()
{
// A { B { m1 60, m2 40 }, m3 25 } plus loose 'x' 10 — the outer set takes 125, the inner
// set line is part of that subtree and is blanked, so 125 is never counted twice.
var (svc, _, _) = NewService();
var s = svc.OpenFromPayload(NestedSetPayload(), "user1");
Assert.Equal(135m, s.Sums.TotalNet);
var s2 = svc.ApplyPatch(s.Token, new InvoiceDraftDelta { Target = "block.setprice" });
Assert.NotNull(s2);
Assert.Equal(125m, LineOf(s2!, "A")["vt"]!.Value<decimal>());
Assert.Equal(JTokenType.Null, LineOf(s2!, "B")["vt"]!.Type); // nested set: blanked with the subtree
Assert.Equal(JTokenType.Null, LineOf(s2!, "m1")["vt"]!.Type);
Assert.Equal(JTokenType.Null, LineOf(s2!, "m3")["vt"]!.Type);
Assert.Equal(10m, LineOf(s2!, "x")["vt"]!.Value<decimal>()); // outside every set: untouched
Assert.Equal(135m, s2!.Sums.TotalNet);
}
[Fact]
public void ApplyPatch_SetOnly_NestedSets_RemovesTheWholeSubtreeIncludingTheNestedSetLine()
{
var (svc, _, _) = NewService();
var s = svc.OpenFromPayload(NestedSetPayload(), "user1");
var s2 = svc.ApplyPatch(s.Token, new InvoiceDraftDelta { Target = "block.setonly" });
Assert.NotNull(s2);
Assert.Equal(new[] { "A", "x" }, LineOrder(s2!));
Assert.Equal(125m, LineOf(s2!, "A")["vt"]!.Value<decimal>());
Assert.Equal(135m, s2!.Sums.TotalNet);
}
[Fact]
public void ApplyPatch_SetPrice_ReflectedInBuildSetDisplay_MembersBlankStandaloneStillPriced()
{
var (svc, _, _) = NewService();
var s = svc.OpenFromPayload(SetsAndLooseItemsPayload(), "user1");
svc.ApplyPatch(s.Token, new InvoiceDraftDelta { Target = "block.setprice" }); svc.ApplyPatch(s.Token, new InvoiceDraftDelta { Target = "block.setprice" });
var state = JObject.FromObject(svc.BuildState(svc.Get(s.Token)!)); var state = JObject.FromObject(svc.BuildState(svc.Get(s.Token)!));
var setDisplay = (JObject)state["setDisplay"]!; var setDisplay = (JObject)state["setDisplay"]!;
Assert.False(setDisplay.ContainsKey("bset_1"));
Assert.Empty(setDisplay); Assert.True(setDisplay["10"]!["p"]!.Value<bool>()); // the set is priced …
Assert.True(setDisplay["10"]!["h"]!.Value<bool>()); // … and emphasised as a set header
Assert.False(setDisplay["11"]!["p"]!.Value<bool>()); // its members show no price
Assert.False(setDisplay["12"]!["p"]!.Value<bool>());
Assert.True(setDisplay["13"]!["p"]!.Value<bool>()); // the item outside the set keeps its price
} }
[Fact] [Theory]
public void ApplyPatch_BlockSetPrice_EmptyOrUnpricedBlocks_IsNoOp() [InlineData("block.setprice")]
[InlineData("block.setonly")]
public void ApplyPatch_SetModes_InvoiceWithoutAnySet_IsNoOp(string target)
{ {
// Nothing is grouped into a set, so there is nothing to collapse — every line stays as it
// is (ADR 0019: the modes never touch lines outside a set), and no version/history is spent.
var (svc, _, _) = NewService(); var (svc, _, _) = NewService();
var s = svc.OpenFromPayload(JObject.Parse(@"{ var s = svc.OpenFromPayload(JObject.Parse(@"{
'admin':{'p13b':false,'type':'r'}, 'admin':{'p13b':false,'type':'r'},
'new':{'invoiceemail':'a@b.de','invoiceaddress':'Weg 1','invoicetitle':'Rechnung'}, 'new':{'invoiceemail':'a@b.de','invoiceaddress':'Weg 1','invoicetitle':'Rechnung'},
'req':[{'Id':'1','text':'Leer','itm':[],'items':[]}] 'req':[{'Id':'1','text':'Auftrag','itm':[
{'id':'11','typ':'material','vt':60,'vv':11.4,'vs':0,'vsv':0,'vat':'19%','SetItmId':null}],
'items':[{'id':'11','type':'material','total_net':60,'vat':'19%'}]},
{'Id':'2','text':'Leer','itm':[],'items':[]}]
}"), "user1");
var s2 = svc.ApplyPatch(s.Token, new InvoiceDraftDelta { Target = target });
Assert.NotNull(s2);
Assert.Equal(0, s2!.Version);
Assert.Empty(s2.History);
Assert.Equal(60m, LineOf(s2, "11")["vt"]!.Value<decimal>());
Assert.Equal(60m, s2.Sums.TotalNet);
}
[Fact]
public void ApplyPatch_SetPrice_EmptySet_IsNoOp()
{
// A set line with no members has nothing to sum — converting it would blank the whole
// invoice's only priced line into a 0,00 € set.
var (svc, _, _) = NewService();
var s = svc.OpenFromPayload(JObject.Parse(@"{
'admin':{'p13b':false,'type':'r'},
'new':{'invoiceemail':'a@b.de','invoiceaddress':'Weg 1','invoicetitle':'Rechnung'},
'req':[{'Id':'1','text':'Auftrag','itm':[
{'id':'10','typ':'set','vt':0,'vv':0,'vs':0,'vsv':0,'vat':'19%','SetItmId':'10'},
{'id':'11','typ':'material','vt':60,'vv':11.4,'vs':0,'vsv':0,'vat':'19%','SetItmId':null}],
'items':[{'id':'10','type':'set','total_net':0},
{'id':'11','type':'material','total_net':60,'vat':'19%'}]}]
}"), "user1"); }"), "user1");
var s2 = svc.ApplyPatch(s.Token, new InvoiceDraftDelta { Target = "block.setprice" }); var s2 = svc.ApplyPatch(s.Token, new InvoiceDraftDelta { Target = "block.setprice" });
Assert.NotNull(s2); Assert.NotNull(s2);
Assert.Equal(0, s2!.Version); // nothing priced -> no-op Assert.Equal(0, s2!.Version);
Assert.Empty(s2.History); Assert.Equal(60m, LineOf(s2, "11")["vt"]!.Value<decimal>());
Assert.Equal(60m, s2.Sums.TotalNet);
} }
[Fact] [Fact]
@@ -569,9 +681,10 @@ public class InvoiceDraftServiceTests
payload["invid"] = "INV7"; payload["invid"] = "INV7";
var s = svc.OpenFromPayload(payload, "user1"); var s = svc.OpenFromPayload(payload, "user1");
await svc.FlushToDbAsync(s.Token, "user1", null!); var result = await svc.FlushToDbAsync(s.Token, "user1", null!);
Assert.True(inv.LastChange); // prior InvId → update path Assert.True(inv.LastChange); // prior InvId → update path
Assert.True(result!.Updated);
} }
[Fact] [Fact]
@@ -592,12 +705,12 @@ public class InvoiceDraftServiceTests
} }
[Fact] [Fact]
public void RenderPreview_SynthesizesDraftRegistrationFromSession() public async Task RenderPreview_SynthesizesDraftRegistrationFromSession()
{ {
var (svc, inv, _) = NewService(); var (svc, inv, _) = NewService();
var s = svc.OpenFromPayload(Payload(), "user1"); var s = svc.OpenFromPayload(Payload(), "user1");
var doc = svc.RenderPreview(s.Token); var doc = await svc.RenderPreviewAsync(s.Token);
Assert.NotNull(doc); Assert.NotNull(doc);
Assert.True(inv.PreviewDraft); // always rendered as a draft Assert.True(inv.PreviewDraft); // always rendered as a draft
@@ -610,10 +723,10 @@ public class InvoiceDraftServiceTests
} }
[Fact] [Fact]
public void RenderPreview_UnknownToken_ReturnsNull() public async Task RenderPreview_UnknownToken_ReturnsNull()
{ {
var (svc, _, _) = NewService(); var (svc, _, _) = NewService();
Assert.Null(svc.RenderPreview("ghost")); Assert.Null(await svc.RenderPreviewAsync("ghost"));
} }
[Fact] [Fact]
@@ -0,0 +1,177 @@
using System;
using System.Collections.Generic;
using System.Threading;
using System.Threading.Tasks;
using Fuchs.intranet;
using Fuchs.Services;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.Logging;
using Moq;
using OCORE.security;
using Xunit;
namespace Fuchs.Tests;
/// <summary>
/// Covers <see cref="InvoiceFileBackupService.BackupAndClearAsync"/>: the invoice-not-found
/// short-circuit, that a blob backup-rename failure never blocks the DB-side backup/clear (best
/// effort per Docs/Decisions/0027), and that the DB call failing is reported without throwing.
/// No SQL Server: the DB-clear path uses an unconfigured connection so the write fails fast inside
/// the OCORE helper, mirroring InvoiceServiceStoreFileTests.
/// </summary>
public class InvoiceFileBackupServiceTests
{
private sealed class RecordingLogger : ILogger<InvoiceFileBackupService>
{
public sealed record Entry(LogLevel Level, Exception? Exception, string Message);
public readonly List<Entry> Entries = new();
public IDisposable? BeginScope<TState>(TState state) where TState : notnull => null;
public bool IsEnabled(LogLevel logLevel) => true;
public void Log<TState>(LogLevel logLevel, EventId eventId, TState state, Exception? exception,
Func<TState, Exception?, string> formatter) =>
Entries.Add(new Entry(logLevel, exception, formatter(state, exception)));
}
// A refused local port: the write fails inside the OCORE helper's own try/catch (returning an
// Exception string) instead of hanging on a connect timeout.
private const string UnreachableConnectionString =
"Server=127.0.0.1,1;Database=fuchs;User Id=u;Password=p;Connect Timeout=1;TrustServerCertificate=True";
private static FdsInvoiceData Invoice(string id = "W7VAL", bool isFinal = true, bool isExternal = false, bool hasFile = true) => new()
{
InvoiceRegistration = new OCORE.OCORE_dictionaries.GenericObjectDictionary(new Dictionary<string, object?>
{
["Id"] = id,
["InvoiceId"] = "R2026-0258",
["DocumentName"] = $"Rechnung_{id}.pdf",
["IsFinal"] = isFinal,
["IsExternal"] = isExternal,
["hasFile"] = hasFile,
}),
IsDraft = !isFinal,
};
private static (InvoiceFileBackupService svc, RecordingLogger log, Mock<IInvoiceService> invoices, Mock<IBlobStorageService> blob)
MakeService(FdsInvoiceData? invoice, string? connectionString = null)
{
var log = new RecordingLogger();
var invoices = new Mock<IInvoiceService>();
invoices.Setup(i => i.LoadInvoiceAsync(It.IsAny<string>(), It.IsAny<string>(), It.IsAny<DatabaseSecurity>()))
.ReturnsAsync(invoice ?? new FdsInvoiceData());
var blob = new Mock<IBlobStorageService>();
var config = new ConfigurationBuilder().AddInMemoryCollection(new Dictionary<string, string?>
{
["ConnectionStrings:fuchs_fds_ConnectionString"] = connectionString
}).Build();
var svc = new InvoiceFileBackupService(new Fuchs_intranet(config), invoices.Object, blob.Object, log);
return (svc, log, invoices, blob);
}
// LoadInvoiceAsync never yields a null registration for an unknown id — it yields an empty
// dictionary (FirstRow.toObjectDictionary()), i.e. an invoice whose Id is "". Both shapes must
// be recognised as "not found" and never fall through to the IsDraft check as "not_final".
public static IEnumerable<object[]> NotFoundShapes() =>
[
[ new FdsInvoiceData() ],
[ new FdsInvoiceData { InvoiceRegistration = new OCORE.OCORE_dictionaries.GenericObjectDictionary(new Dictionary<string, object?>()) } ],
];
[Theory]
[MemberData(nameof(NotFoundShapes))]
public async Task BackupAndClearAsync_InvoiceNotFound_ReturnsFailureWithoutTouchingBlobOrDb(FdsInvoiceData loaded)
{
var (svc, log, invoices, blob) = MakeService(loaded);
var result = await svc.BackupAndClearAsync("MISSING", "user1", new DatabaseSecurity("user1", null));
Assert.False(result.Success);
Assert.Equal("invoice_not_found", result.Reason);
Assert.Null(result.BlobBackupUri);
blob.Verify(b => b.CopyInvoicePdfToBackupAsync(It.IsAny<string>(), It.IsAny<string>(), It.IsAny<CancellationToken>()),
Times.Never);
var entry = Assert.Single(log.Entries);
Assert.Equal(LogLevel.Warning, entry.Level);
Assert.Contains("not found", entry.Message);
}
// ADR 0025's regeneration precondition is load-bearing: clearing a draft's file would let the
// next read persist a draft render that then blocks the real final PDF forever; an external
// (MFR) document can't be regenerated at all. Both must be refused before anything is touched.
[Theory]
[InlineData(false, false, "not_final")]
[InlineData(true, true, "external")]
[InlineData(false, true, "not_final")]
public async Task BackupAndClearAsync_DraftOrExternalInvoice_RefusesWithoutTouchingBlobOrDb(
bool isFinal, bool isExternal, string expectedReason)
{
var (svc, log, invoices, blob) = MakeService(Invoice(isFinal: isFinal, isExternal: isExternal), UnreachableConnectionString);
var result = await svc.BackupAndClearAsync("W7VAL", "user1", new DatabaseSecurity("user1", null));
Assert.False(result.Success);
Assert.Equal(expectedReason, result.Reason);
Assert.Null(result.BlobBackupUri);
blob.Verify(b => b.CopyInvoicePdfToBackupAsync(It.IsAny<string>(), It.IsAny<string>(), It.IsAny<CancellationToken>()),
Times.Never);
var entry = Assert.Single(log.Entries);
Assert.Equal(LogLevel.Warning, entry.Level);
Assert.Contains("refused", entry.Message);
Assert.DoesNotContain(log.Entries, e => e.Level == LogLevel.Error);
}
[Fact]
public async Task BackupAndClearAsync_NoStoredFile_ReturnsNoFileWithoutCopyingBlobOrTouchingDb()
{
// A repeated click on an already-cleared invoice must not leave another backup_* blob
// behind: fds__getInvoice's hasFile settles it before the copy and before the proc.
var (svc, log, invoices, blob) = MakeService(Invoice(hasFile: false), UnreachableConnectionString);
var result = await svc.BackupAndClearAsync("W7VAL", "user1", new DatabaseSecurity("user1", null));
Assert.False(result.Success);
Assert.Equal("no_file", result.Reason);
Assert.Null(result.BlobBackupUri);
blob.Verify(b => b.CopyInvoicePdfToBackupAsync(It.IsAny<string>(), It.IsAny<string>(), It.IsAny<CancellationToken>()),
Times.Never);
Assert.DoesNotContain(log.Entries, e => e.Level == LogLevel.Error); // the proc was never called
Assert.Contains(log.Entries, e => e.Level == LogLevel.Warning && e.Message.Contains("no file stored"));
}
[Fact]
public async Task BackupAndClearAsync_BlobCopySucceeds_DbFails_ReportsDbErrorWithBackupUri()
{
// The backup copy is taken BEFORE the DB clear (so no regeneration can overwrite the
// original first) and the original blob is kept; a DB failure (here: no real SQL Server)
// therefore leaves the archive intact plus a spare copy, which the result still reports.
var backupUri = new Uri("https://test.blob.core.windows.net/invoices/W7VAL/backup_x.pdf");
var (svc, log, invoices, blob) = MakeService(Invoice(), UnreachableConnectionString);
blob.Setup(b => b.CopyInvoicePdfToBackupAsync("W7VAL", "Rechnung_W7VAL.pdf", It.IsAny<CancellationToken>()))
.ReturnsAsync(backupUri);
var result = await svc.BackupAndClearAsync("W7VAL", "user1", new DatabaseSecurity("user1", null));
Assert.False(result.Success);
Assert.Equal("db_error", result.Reason);
Assert.Equal(backupUri, result.BlobBackupUri);
blob.Verify(b => b.CopyInvoicePdfToBackupAsync("W7VAL", "Rechnung_W7VAL.pdf", It.IsAny<CancellationToken>()), Times.Once);
Assert.Contains(log.Entries, e => e.Level == LogLevel.Error && e.Message.Contains("fds__admin_backupAndClearInvoiceFile failed"));
}
[Fact]
public async Task BackupAndClearAsync_BlobCopyThrows_StillAttemptsDbClearAndReturnsDbOutcome()
{
var (svc, log, invoices, blob) = MakeService(Invoice(), UnreachableConnectionString);
blob.Setup(b => b.CopyInvoicePdfToBackupAsync(It.IsAny<string>(), It.IsAny<string>(), It.IsAny<CancellationToken>()))
.ThrowsAsync(new InvalidOperationException("blob boom"));
var result = await svc.BackupAndClearAsync("W7VAL", "user1", new DatabaseSecurity("user1", null));
// The blob exception is swallowed and logged; the DB step still runs (and fails here for
// lack of a SQL Server) rather than the exception escaping BackupAndClearAsync.
Assert.False(result.Success);
Assert.Equal("db_error", result.Reason);
Assert.Null(result.BlobBackupUri);
Assert.Contains(log.Entries, e => e.Level == LogLevel.Error && e.Message.Contains("blob backup-copy threw"));
Assert.Contains(log.Entries, e => e.Level == LogLevel.Error && e.Message.Contains("fds__admin_backupAndClearInvoiceFile failed"));
}
}
+303
View File
@@ -0,0 +1,303 @@
using System;
using System.Collections.Generic;
using System.Diagnostics.Metrics;
using System.Linq;
using System.Net;
using System.Net.Http;
using System.Threading;
using System.Threading.Tasks;
using Fuchs.intranet;
using Fuchs.Notifications;
using Fuchs.Services;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.Logging.Abstractions;
using Moq;
using Xunit;
namespace Fuchs.Tests;
/// <summary>
/// Exercises <see cref="InvoiceService.ResolveInlineImagesAsync"/> — the whitelist-restricted image
/// fetch that lets invoice-editor picture links (mfr portal only) appear in the rendered PDF
/// (ADR 0018). Covers the whitelist boundary (derived from the <c>Fds:MFR_host</c> config entry,
/// not a second hardcoded host), fetch failure modes (each must just drop that one image, never
/// throw), and the per-invoice fetch cap.
/// </summary>
public class InvoiceInlineImageTests
{
private sealed class StubHandler : HttpMessageHandler
{
public readonly List<string> RequestedUrls = new();
private readonly Func<HttpRequestMessage, HttpResponseMessage> _respond;
public StubHandler(Func<HttpRequestMessage, HttpResponseMessage> respond) => _respond = respond;
protected override Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken ct)
{
RequestedUrls.Add(request.RequestUri!.ToString());
return Task.FromResult(_respond(request));
}
}
/// <summary>Delays each response and records how many requests were in flight at once, so a test
/// can assert fetches actually overlapped instead of inferring it from timing alone.</summary>
private sealed class ConcurrencyTrackingHandler : HttpMessageHandler
{
private int _inFlight;
public int MaxObservedConcurrent;
private readonly TimeSpan _delay;
private readonly Func<HttpRequestMessage, HttpResponseMessage> _respond;
public ConcurrencyTrackingHandler(TimeSpan delay, Func<HttpRequestMessage, HttpResponseMessage> respond)
{
_delay = delay;
_respond = respond;
}
protected override async Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken ct)
{
int now = Interlocked.Increment(ref _inFlight);
InterlockedMax(ref MaxObservedConcurrent, now);
try { await Task.Delay(_delay, ct); return _respond(request); }
finally { Interlocked.Decrement(ref _inFlight); }
}
private static void InterlockedMax(ref int target, int value)
{
int initial;
do { initial = target; if (value <= initial) return; }
while (Interlocked.CompareExchange(ref target, value, initial) != initial);
}
}
private sealed class StubFactory : IHttpClientFactory
{
private readonly HttpMessageHandler _handler;
public StubFactory(HttpMessageHandler handler) => _handler = handler;
public HttpClient CreateClient(string name) => new(_handler, disposeHandler: false);
}
private static IConfiguration TestConfig(string mfrHost = "portal.mobilefieldreport.com") =>
new ConfigurationBuilder().AddInMemoryCollection(new Dictionary<string, string?>
{
["Fds:MFR_host"] = mfrHost
}).Build();
private static InvoiceService MakeService(HttpMessageHandler handler, string mfrHost = "portal.mobilefieldreport.com") => new(
new Fuchs_intranet(new ConfigurationBuilder().Build()),
Mock.Of<IPdfService>(),
Mock.Of<IBlobStorageService>(),
Mock.Of<IEventService>(),
Mock.Of<IERechnungService>(),
new StubFactory(handler),
TestConfig(mfrHost),
NullLogger<InvoiceService>.Instance);
private static FdsInvoiceData InvoiceWithDesc(params string[] descs)
{
var items = new List<Dictionary<string, object>>();
foreach (var d in descs) items.Add(new Dictionary<string, object> { ["type"] = "material", ["desc"] = d });
return new FdsInvoiceData
{
Req = new List<Dictionary<string, object>> { new() { ["text"] = "Block", ["items"] = items } }
};
}
private static HttpResponseMessage ImageResponse(byte[] bytes) =>
new(HttpStatusCode.OK) { Content = new ByteArrayContent(bytes) { Headers = { ContentType = new("image/png") } } };
[Fact]
public void AllowedInlineImagePrefix_DerivedFromConfiguredMfrHost_NotHardcodedSecondTime()
{
var svc = MakeService(new StubHandler(_ => ImageResponse(Array.Empty<byte>())),
mfrHost: "other-mfr-host.example.com");
Assert.Equal("https://other-mfr-host.example.com/mfr/", svc.AllowedInlineImagePrefix);
}
[Fact]
public async Task ResolveInlineImagesAsync_WhitelistedUrl_ReturnsBase64DataForOriginalSrc()
{
byte[] bytes = { 1, 2, 3, 4 };
var handler = new StubHandler(_ => ImageResponse(bytes));
var svc = MakeService(handler);
string url = svc.AllowedInlineImagePrefix + "pic1.png";
var invoice = InvoiceWithDesc($"<p>Before</p><img src=\"{url}\"><p>After</p>");
var result = await svc.ResolveInlineImagesAsync(invoice);
Assert.True(result.ContainsKey(url));
Assert.Equal("base64:" + Convert.ToBase64String(bytes), result[url]);
Assert.Single(handler.RequestedUrls);
}
[Theory]
[InlineData("https://evil.example.com/mfr/pic1.png")]
[InlineData("https://portal.mobilefieldreport.com.evil.com/mfr/pic1.png")]
[InlineData("http://portal.mobilefieldreport.com/mfr/pic1.png")]
[InlineData("https://portal.mobilefieldreport.com/other/pic1.png")]
public async Task ResolveInlineImagesAsync_NonWhitelistedSrc_NeverFetched(string url)
{
var handler = new StubHandler(_ => ImageResponse(new byte[] { 1 }));
var invoice = InvoiceWithDesc($"<img src=\"{url}\">");
var result = await MakeService(handler).ResolveInlineImagesAsync(invoice);
Assert.Empty(result);
Assert.Empty(handler.RequestedUrls);
}
[Fact]
public async Task ResolveInlineImagesAsync_DecoyAttributeNextToRealSrc_FetchesOnlyRealSrc()
{
// A description containing something like data-src alongside the real src must never let
// the decoy be picked up in place of (or in addition to) the actual src attribute.
var handler = new StubHandler(_ => ImageResponse(new byte[] { 1 }));
var svc = MakeService(handler);
string real = svc.AllowedInlineImagePrefix + "real.png";
string decoy = svc.AllowedInlineImagePrefix + "decoy.png";
var invoice = InvoiceWithDesc($"<img data-src=\"{decoy}\" src=\"{real}\">");
var result = await svc.ResolveInlineImagesAsync(invoice);
Assert.Single(result);
Assert.True(result.ContainsKey(real));
Assert.False(result.ContainsKey(decoy));
Assert.DoesNotContain(decoy, handler.RequestedUrls);
}
[Fact]
public async Task ResolveInlineImagesAsync_NoImages_SkipsHttpEntirely()
{
var handler = new StubHandler(_ => ImageResponse(new byte[] { 1 }));
var invoice = InvoiceWithDesc("<p>Just text, no picture.</p>");
var result = await MakeService(handler).ResolveInlineImagesAsync(invoice);
Assert.Empty(result);
Assert.Empty(handler.RequestedUrls);
}
[Fact]
public async Task ResolveInlineImagesAsync_HttpErrorStatus_DropsImageWithoutThrowing()
{
var handler = new StubHandler(_ => new HttpResponseMessage(HttpStatusCode.NotFound));
var svc = MakeService(handler);
string url = svc.AllowedInlineImagePrefix + "missing.png";
var invoice = InvoiceWithDesc($"<img src=\"{url}\">");
var result = await svc.ResolveInlineImagesAsync(invoice);
Assert.Empty(result);
}
[Fact]
public async Task ResolveInlineImagesAsync_NonImageContentType_DropsImage()
{
var handler = new StubHandler(_ => new HttpResponseMessage(HttpStatusCode.OK)
{
Content = new ByteArrayContent(new byte[] { 1, 2 }) { Headers = { ContentType = new("text/html") } }
});
var svc = MakeService(handler);
string url = svc.AllowedInlineImagePrefix + "notreally.png";
var invoice = InvoiceWithDesc($"<img src=\"{url}\">");
var result = await svc.ResolveInlineImagesAsync(invoice);
Assert.Empty(result);
}
[Fact]
public async Task ResolveInlineImagesAsync_ThrowingHandler_DropsImageWithoutThrowing()
{
var handler = new StubHandler(_ => throw new HttpRequestException("network down"));
var svc = MakeService(handler);
string url = svc.AllowedInlineImagePrefix + "boom.png";
var invoice = InvoiceWithDesc($"<img src=\"{url}\">");
var result = await svc.ResolveInlineImagesAsync(invoice);
Assert.Empty(result);
}
[Fact]
public async Task ResolveInlineImagesAsync_DuplicateSrcAcrossLines_FetchedOnce()
{
var handler = new StubHandler(_ => ImageResponse(new byte[] { 9 }));
var svc = MakeService(handler);
string url = svc.AllowedInlineImagePrefix + "shared.png";
var invoice = InvoiceWithDesc($"<img src=\"{url}\">", $"<img src=\"{url}\">");
var result = await svc.ResolveInlineImagesAsync(invoice);
Assert.Single(result);
Assert.Single(handler.RequestedUrls);
}
[Fact]
public async Task ResolveInlineImagesAsync_MoreThanCapDistinctImages_StopsAtCap()
{
var handler = new StubHandler(_ => ImageResponse(new byte[] { 1 }));
var svc = MakeService(handler);
// 25 distinct URLs on one invoice; the 20-image-per-invoice cap must stop fetching at 20.
var descs = Enumerable.Range(1, 25)
.Select(i => $"<img src=\"{svc.AllowedInlineImagePrefix}img{i}.png\">")
.ToArray();
var invoice = InvoiceWithDesc(descs);
var result = await svc.ResolveInlineImagesAsync(invoice);
Assert.Equal(20, result.Count);
Assert.Equal(20, handler.RequestedUrls.Count);
}
[Fact]
public async Task ResolveInlineImagesAsync_MultipleImages_FetchesConcurrentlyNotOneAtATime()
{
var handler = new ConcurrencyTrackingHandler(TimeSpan.FromMilliseconds(150), _ => ImageResponse(new byte[] { 1 }));
var svc = MakeService(handler);
var descs = Enumerable.Range(1, 4)
.Select(i => $"<img src=\"{svc.AllowedInlineImagePrefix}img{i}.png\">")
.ToArray();
var invoice = InvoiceWithDesc(descs);
await svc.ResolveInlineImagesAsync(invoice);
Assert.True(handler.MaxObservedConcurrent > 1,
$"expected overlapping fetches, but max observed concurrency was {handler.MaxObservedConcurrent}");
}
[Fact]
public async Task ResolveInlineImagesAsync_Success_RecordsSuccessCounterAndResolveDuration()
{
long fetchOutcomeDelta = 0;
int durationRecords = 0;
using var listener = new MeterListener
{
InstrumentPublished = (inst, l) =>
{
if (inst.Meter.Name != "Fuchs.Intranet") return;
if (inst.Name is "fuchs.invoice.inlineimages.fetches" or "fuchs.invoice.inlineimages.resolve.duration")
l.EnableMeasurementEvents(inst);
}
};
listener.SetMeasurementEventCallback<long>((inst, value, tags, _) =>
{
if (inst.Name == "fuchs.invoice.inlineimages.fetches")
foreach (var t in tags)
if (t.Key == "outcome" && (string?)t.Value == "success")
Interlocked.Add(ref fetchOutcomeDelta, value);
});
listener.SetMeasurementEventCallback<double>((inst, _, _, _) =>
{
if (inst.Name == "fuchs.invoice.inlineimages.resolve.duration")
Interlocked.Increment(ref durationRecords);
});
listener.Start();
var handler = new StubHandler(_ => ImageResponse(new byte[] { 1 }));
var svc = MakeService(handler);
string url = svc.AllowedInlineImagePrefix + "pic.png";
var invoice = InvoiceWithDesc($"<img src=\"{url}\">");
await svc.ResolveInlineImagesAsync(invoice);
Assert.Equal(1, fetchOutcomeDelta);
Assert.True(durationRecords >= 1, "resolve-duration histogram should have recorded one measurement.");
}
}
+32
View File
@@ -0,0 +1,32 @@
using Fuchs.Services;
using Xunit;
namespace Fuchs.Tests;
/// <summary>
/// <c>fds__invoice_items.reference_InvId</c> links a CanceledInvoice / IntInvoiceAllocation line to the
/// invoice it cancels / credits. It was never persisted from the editor, so fds__setInvoiceFinal
/// could not record allocations (and a NULL reference aborted its MERGE). The posted <c>ref</c> is
/// normalised by <see cref="InvoiceService.ItemReferenceInvId"/>.
/// </summary>
public class InvoiceItemReferenceTests
{
[Theory]
[InlineData("XH6EH", "XH6EH")]
[InlineData(" 6BOY0 ", "6BOY0")]
[InlineData("12345678901234567890", "12345678901234567890")] // exactly varchar(20)
public void ItemReferenceInvId_ValidId_ReturnsTrimmedId(string raw, string expected)
=> Assert.Equal(expected, InvoiceService.ItemReferenceInvId(raw));
[Theory]
[InlineData(null)]
[InlineData("")]
[InlineData(" ")]
[InlineData("null")]
[InlineData("NULL")]
[InlineData("123456789012345678901")] // longer than the column
[InlineData("AB'; DROP")]
[InlineData("R2025-0244")] // an invoice *number*, not an invoice id
public void ItemReferenceInvId_InvalidOrMissing_ReturnsNull(string? raw)
=> Assert.Null(InvoiceService.ItemReferenceInvId(raw));
}
@@ -0,0 +1,85 @@
using System;
using System.Collections.Generic;
using System.Net.Http;
using System.Threading.Tasks;
using Fuchs.intranet;
using Fuchs.Notifications;
using Fuchs.Services;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.Logging.Abstractions;
using MigraDoc.DocumentObjectModel;
using Moq;
using Xunit;
namespace Fuchs.Tests;
/// <summary>
/// The stored Leistungsdatum / -zeitraum must reach the invoice PDF's admin block (it was never
/// passed to the text blocks, so every invoice printed "Leistungszeitraum: -").
/// </summary>
public class InvoicePdfProvisionPeriodTests
{
private sealed class NoHttp : IHttpClientFactory
{
public HttpClient CreateClient(string name) => throw new InvalidOperationException("no HTTP expected");
}
[Theory]
[InlineData("2026-06-18", "18.06.2026")]
[InlineData("2026-06-01 - 2026-06-30", "01.06.2026 - 30.06.2026")]
[InlineData("18.06.2026", "18.06.2026")]
[InlineData("01.06.2026 - 30.06.2026", "01.06.2026 - 30.06.2026")]
[InlineData("R2026-0258", "R2026-0258")]
[InlineData("", "")]
[InlineData(null, "")]
[InlineData("nach Absprache", "nach Absprache")]
public void ProvisionPeriodForDisplay_MixedFormats_ShowsGermanDates(string? input, string expected)
{
Assert.Equal(expected, InvoiceService.ProvisionPeriodForDisplay(input));
}
[Theory]
[InlineData("18.06.2026", "18.06.2026", "Leistungsdatum")]
[InlineData("01.06.2026 - 30.06.2026", "01.06.2026 - 30.06.2026", "Leistungszeitraum")]
[InlineData("2026-06-18", "18.06.2026", "Leistungsdatum")]
[InlineData("2026-06-01 - 2026-06-30", "01.06.2026 - 30.06.2026", "Leistungszeitraum")]
[InlineData(" 18.06.2026 ", "18.06.2026", "Leistungsdatum")]
[InlineData("", "", "Leistungszeitraum")]
[InlineData(null, "", "Leistungszeitraum")]
public void ApplyProvisionPeriod_StoredValue_SetsGermanValueAndMatchingLabel(string? stored, string expectedValue, string expectedLabel)
{
var tb = new FuchsPdf.FdsTextBlocks();
InvoiceService.ApplyProvisionPeriod(tb, stored);
Assert.Equal(expectedValue, tb.ProvisionPeriod);
Assert.Equal(expectedLabel, tb.AdminProvLabel);
}
[Theory]
[InlineData("18.06.2026", "18.06.2026", "Leistungsdatum")]
[InlineData("2026-06-01 - 2026-06-30", "01.06.2026 - 30.06.2026", "Leistungszeitraum")]
[InlineData(null, "", "Leistungszeitraum")]
public async Task GenerateInvoicePdfAsync_StoredProvisionPeriod_ReachesAdminBlock(string? stored, string expectedValue, string expectedLabel)
{
FuchsPdf.FdsTextBlocks? captured = null;
var pdf = new Mock<IPdfService>();
pdf.Setup(p => p.WriteLetterAsync(It.IsAny<FuchsPdf.FdsTextBlocks>(), It.IsAny<bool>()))
.Callback<FuchsPdf.FdsTextBlocks, bool>((tb, _) => captured = tb)
.ReturnsAsync(new Document());
var erechnung = new Mock<IERechnungService>();
erechnung.SetupGet(e => e.Enabled).Returns(false);
var config = new ConfigurationBuilder().Build();
var svc = new InvoiceService(new Fuchs_intranet(config), pdf.Object, new Mock<IBlobStorageService>().Object,
new Mock<IEventService>().Object, erechnung.Object, new NoHttp(), config, NullLogger<InvoiceService>.Instance);
var reg = new Dictionary<string, object?> { ["Id"] = "W7VAL", ["InvoiceId"] = "R2026-0258" };
if (stored != null) reg["ProvisionPeriod"] = stored;
var invoice = new FdsInvoiceData { InvoiceRegistration = new OCORE.OCORE_dictionaries.GenericObjectDictionary(reg) };
await svc.GenerateInvoicePdfAsync(invoice, draft: true);
Assert.NotNull(captured);
Assert.Equal(expectedValue, captured!.ProvisionPeriod);
Assert.Equal(expectedLabel, captured.AdminProvLabel);
}
}
+39
View File
@@ -0,0 +1,39 @@
using Fuchs.Controllers;
using Xunit;
namespace Fuchs.Tests;
/// <summary>
/// <c>inv/pget</c> is the first call of both storno paths. Intranet-created invoices carry an
/// alphanumeric Id (<c>fds__fn_invoice_id</c>, e.g. "XH6EH"); rejecting those with 400 left the
/// storno window empty. Only mfr invoice ids (positive integers) may trigger the mfr reset.
/// </summary>
public class InvoicePgetIdTests
{
[Theory]
[InlineData("12345", 12345L)]
[InlineData(" 987654321 ", 987654321L)]
[InlineData("9223372036854775807", long.MaxValue)]
public void TryGetMfrInvoiceId_NumericMfrId_ReturnsTrueWithValue(string raw, long expected)
{
Assert.True(IntranetController.TryGetMfrInvoiceId(raw, out long id));
Assert.Equal(expected, id);
}
[Theory]
[InlineData("XH6EH")] // Intranet invoice id
[InlineData("6BOY0")] // alphanumeric, starts with a digit
[InlineData("00000")] // numeric but not a valid (positive) mfr id
[InlineData("0")]
[InlineData("-5")]
[InlineData("1.5")]
[InlineData("1e3")]
[InlineData("9223372036854775808")] // overflow
[InlineData("")]
[InlineData(" ")]
[InlineData(null)]
public void TryGetMfrInvoiceId_NonMfrId_ReturnsFalse(string? raw)
{
Assert.False(IntranetController.TryGetMfrInvoiceId(raw, out _));
}
}
+62
View File
@@ -0,0 +1,62 @@
using System;
using Fuchs.Services;
using Xunit;
namespace Fuchs.Tests;
/// <summary>
/// The invoice header save batch must stop as soon as fds__createInvoice / fds__setInvoice fails:
/// previously a parameter conversion error left @Id NULL and the batch went on to
/// fds__createInvoice_Details, whose "Cannot insert NULL into InvId" buried the real error.
/// </summary>
public class InvoiceRegisterSqlTests
{
[Theory]
[InlineData(false, "[dbo].[fds__createInvoice] ", "fds__createInvoice did not return")]
[InlineData(true, "[dbo].[fds__setInvoice] ", "fds__setInvoice did not return")]
public void BuildRegisterInvoiceSql_GuardSitsBetweenHeaderProcAndDetails(bool change, string headerProc, string guardText)
{
string sql = InvoiceService.BuildRegisterInvoiceSql(change, hasProvisionLocation: true);
int header = sql.IndexOf(headerProc, StringComparison.Ordinal);
int guard = sql.IndexOf("IF @Id IS NULL THROW", StringComparison.Ordinal);
int details = sql.IndexOf("[dbo].[fds__createInvoice_Details]", StringComparison.Ordinal);
int update = sql.IndexOf("UPDATE [dbo].[fds__invoices]", StringComparison.Ordinal);
Assert.True(header >= 0, "header proc missing");
Assert.True(header < guard && guard < details && details < update, sql);
Assert.Contains(guardText, sql);
}
[Theory]
[InlineData(false)]
[InlineData(true)]
public void BuildRegisterInvoiceSql_ExactlyOneHeaderProcPerMode(bool change)
{
string sql = InvoiceService.BuildRegisterInvoiceSql(change, hasProvisionLocation: false);
Assert.Equal(change, sql.Contains("[dbo].[fds__setInvoice] @InvId,"));
Assert.Equal(!change, sql.Contains("[dbo].[fds__createInvoice] @InvoiceType,"));
Assert.Equal(change, sql.Contains("@InvId"));
}
[Theory]
[InlineData(false, false)]
[InlineData(true, true)]
public void BuildRegisterInvoiceSql_ProvisionLocationUpdateOnlyWhenPresent(bool hasProvisionLocation, bool expected)
{
string sql = InvoiceService.BuildRegisterInvoiceSql(change: true, hasProvisionLocation);
Assert.Equal(expected, sql.Contains("@ProvisionLocation"));
}
[Fact]
public void BuildRegisterInvoiceSql_EveryStatementIsSemicolonTerminated()
{
// THROW requires the preceding statement to be terminated with ';'.
string sql = InvoiceService.BuildRegisterInvoiceSql(change: true, hasProvisionLocation: true);
foreach (var line in sql.Split('\n'))
Assert.EndsWith(";", line.TrimEnd());
}
}
+149
View File
@@ -0,0 +1,149 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Net.Http;
using System.Threading;
using System.Threading.Tasks;
using Fuchs.intranet;
using Fuchs.Notifications;
using Fuchs.Services;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.Logging;
using MigraDoc.DocumentObjectModel;
using Moq;
using OCORE.security;
using Xunit;
namespace Fuchs.Tests;
/// <summary>
/// Exercises the failure branches of <see cref="InvoiceService.StoreInvoiceDocumentFileAsync"/>.
/// The method signals every failure with an empty array, so the three distinct causes — render
/// threw, render produced no bytes, DB store failed — are only distinguishable by what gets
/// logged. Each must be logged individually, and none may reach the blob upload or the
/// InvoiceFileCreated event. No SQL Server: the store-failure case uses an unconfigured
/// connection so the write fails fast inside the OCORE helper.
/// </summary>
public class InvoiceServiceStoreFileTests
{
private sealed class RecordingLogger : ILogger<InvoiceService>
{
public sealed record Entry(LogLevel Level, Exception? Exception, string Message);
public readonly List<Entry> Entries = new();
public IDisposable? BeginScope<TState>(TState state) where TState : notnull => null;
public bool IsEnabled(LogLevel logLevel) => true;
public void Log<TState>(LogLevel logLevel, EventId eventId, TState state, Exception? exception,
Func<TState, Exception?, string> formatter) =>
Entries.Add(new Entry(logLevel, exception, formatter(state, exception)));
}
private sealed class NoHttp : IHttpClientFactory
{
public HttpClient CreateClient(string name) => throw new InvalidOperationException("no HTTP expected");
}
// A refused local port: the fds__setInvoiceFile write fails inside the OCORE helper's own
// try/catch (returning false) instead of hanging on a connect timeout.
private const string UnreachableConnectionString =
"Server=127.0.0.1,1;Database=fuchs;User Id=u;Password=p;Connect Timeout=1;TrustServerCertificate=True";
private static (InvoiceService svc, RecordingLogger log, Mock<IBlobStorageService> blob, Mock<IEventService> events)
MakeService(Mock<IPdfService> pdf, string? connectionString = null)
{
var log = new RecordingLogger();
var blob = new Mock<IBlobStorageService>();
var events = new Mock<IEventService>();
var erechnung = new Mock<IERechnungService>();
erechnung.SetupGet(e => e.Enabled).Returns(false);
var config = new ConfigurationBuilder().AddInMemoryCollection(new Dictionary<string, string?>
{
["ConnectionStrings:fuchs_fds_ConnectionString"] = connectionString
}).Build();
var svc = new InvoiceService(
new Fuchs_intranet(config),
pdf.Object, blob.Object, events.Object, erechnung.Object,
new NoHttp(), config, log);
return (svc, log, blob, events);
}
private static FdsInvoiceData Invoice(bool isFinal) => new()
{
InvoiceRegistration = new OCORE.OCORE_dictionaries.GenericObjectDictionary(new Dictionary<string, object?>
{
["Id"] = "W7VAL",
["InvoiceId"] = "R2026-0258",
["IsFinal"] = isFinal
}),
IsDraft = !isFinal
};
private static Mock<IPdfService> PdfReturning(byte[] bytes)
{
var pdf = new Mock<IPdfService>();
pdf.Setup(p => p.WriteLetterAsync(It.IsAny<FuchsPdf.FdsTextBlocks>(), It.IsAny<bool>()))
.ReturnsAsync(new Document());
pdf.Setup(p => p.DocToPdfBytes(It.IsAny<Document>())).Returns(bytes);
return pdf;
}
private static void AssertNothingPersisted(Mock<IBlobStorageService> blob, Mock<IEventService> events)
{
blob.Verify(b => b.UploadInvoicePdfAsync(It.IsAny<string>(), It.IsAny<string>(), It.IsAny<byte[]>(),
It.IsAny<IReadOnlyDictionary<string, object?>>(), It.IsAny<CancellationToken>()), Times.Never);
events.Verify(e => e.InvoiceFileCreatedAsync(It.IsAny<FdsInvoiceData>(), It.IsAny<string>(), It.IsAny<string>()),
Times.Never);
}
[Theory]
[InlineData(true)]
[InlineData(false)]
public async Task StoreInvoiceDocumentFileAsync_RenderProducesZeroBytes_ReturnsEmptyAndLogsError(bool draft)
{
var (svc, log, blob, events) = MakeService(PdfReturning(Array.Empty<byte>()));
var result = await svc.StoreInvoiceDocumentFileAsync(Invoice(isFinal: !draft), draft, "user1", new DatabaseSecurity("user1", null));
Assert.Empty(result);
var entry = Assert.Single(log.Entries, e => e.Level == LogLevel.Error);
Assert.Null(entry.Exception);
Assert.Contains("produced 0 bytes", entry.Message);
Assert.Contains("W7VAL", entry.Message);
Assert.Contains($"draft={draft}", entry.Message);
AssertNothingPersisted(blob, events);
}
[Fact]
public async Task StoreInvoiceDocumentFileAsync_RenderThrows_ReturnsEmptyAndLogsExceptionNotZeroBytes()
{
var pdf = new Mock<IPdfService>();
var boom = new InvalidOperationException("font resolver exploded");
pdf.Setup(p => p.WriteLetterAsync(It.IsAny<FuchsPdf.FdsTextBlocks>(), It.IsAny<bool>())).ThrowsAsync(boom);
var (svc, log, blob, events) = MakeService(pdf);
var result = await svc.StoreInvoiceDocumentFileAsync(Invoice(isFinal: true), false, "user1", new DatabaseSecurity("user1", null));
Assert.Empty(result);
var entry = Assert.Single(log.Entries, e => e.Level == LogLevel.Error);
Assert.Same(boom, entry.Exception);
Assert.Contains("render failed", entry.Message);
Assert.DoesNotContain(log.Entries, e => e.Message.Contains("produced 0 bytes"));
AssertNothingPersisted(blob, events);
}
[Fact]
public async Task StoreInvoiceDocumentFileAsync_RenderOkButDbStoreFails_ReturnsEmptyWithoutRenderErrorAndSkipsBlob()
{
// Render succeeds; the fds__setInvoiceFile write fails (no connection configured). The
// SQL error is reported through FIS_SQLOptions.OnError, not through the render branches —
// so neither render message may appear, and the PDF must not be archived to blob storage
// as if the primary DB write had succeeded.
var (svc, log, blob, events) = MakeService(PdfReturning(new byte[] { 0x25, 0x50, 0x44, 0x46 }), UnreachableConnectionString);
var result = await svc.StoreInvoiceDocumentFileAsync(Invoice(isFinal: true), false, "user1", new DatabaseSecurity("user1", null));
Assert.Empty(result);
Assert.DoesNotContain(log.Entries, e => e.Message.Contains("produced 0 bytes"));
Assert.DoesNotContain(log.Entries, e => e.Message.Contains("render failed"));
AssertNothingPersisted(blob, events);
}
}
+12
View File
@@ -60,6 +60,18 @@ public class InvoiceSetPricingTests
Assert.Equal(SetDisplayMode.SetPrice, InvoiceSetPricing.ModeFromInvoiceOptions("setmode:itemprices")); Assert.Equal(SetDisplayMode.SetPrice, InvoiceSetPricing.ModeFromInvoiceOptions("setmode:itemprices"));
} }
[Fact]
public void Build_MemberWithNullTotal_UnderUnconvertedHeader_ShowsNoPrice()
{
// A price-nulled member ("no price", ADR 0009/0019) must render an empty cell even when its
// header reads as unconverted again (e.g. hand-edited back to 0) — never 0,00 €.
var header = SetHeader("10", "Set"); // total 0 -> unconverted
var nulled = Member("10", "A", "0", "0"); nulled["total_net"] = null;
var lines = InvoiceSetPricing.Build(new List<Dictionary<string, object?>> { header, nulled, Member("10", "B", "5.00", "5.00") }, SetDisplayMode.SetPrice);
Assert.False(lines[1].ShowPrice);
Assert.True(lines[2].ShowPrice); // a real price still shows
}
[Fact] [Fact]
public void ContainsSets_DetectsHeaderOrMember() public void ContainsSets_DetectsHeaderOrMember()
{ {
+109
View File
@@ -0,0 +1,109 @@
using System.Collections.Generic;
using System.Linq;
using MigraDoc.DocumentObjectModel;
using MigraDoc.DocumentObjectModel.Shapes;
using MigraDoc.DocumentObjectModel.Tables;
using MigraDoc.Extensions;
using Xunit;
namespace Fuchs.Tests;
/// <summary>
/// Exercises the HTML→MigraDoc converter's &lt;img&gt; handling (added alongside the invoice-text
/// image feature, ADR 0018): only pre-resolved sources render, they land inline at the position the
/// HTML puts them (not appended/floating), and their size mirrors the HTML width/height, capped
/// against layout-breaking values.
/// </summary>
public class MigraDocHtmlImageTests
{
private const string DataSrc = "base64:AAAA";
private static Cell NewCell()
{
var doc = new Document();
var sec = doc.AddSection();
var table = sec.AddTable();
table.AddColumn();
var row = table.AddRow();
return row.Cells[0];
}
[Fact]
public void AddHtml_UnresolvedImgSrc_IsDropped()
{
var cell = NewCell();
cell.AddHtml("<div>Text <img src=\"https://not-fetched.example/x.png\"> more</div>", null);
var para = Assert.IsType<Paragraph>(cell.Elements[0]);
Assert.DoesNotContain(para.Elements.Cast<DocumentObject>(), e => e is Image);
}
[Fact]
public void AddHtml_ResolvedImgSrc_RendersInlineAtHtmlPosition()
{
var cell = NewCell();
var images = new Dictionary<string, string> { ["x"] = DataSrc };
cell.AddHtml("<div>Before <img src=\"x\"> After</div>", images);
var para = Assert.IsType<Paragraph>(cell.Elements[0]);
var kinds = para.Elements.Cast<DocumentObject>().Select(e => e.GetType().Name).ToList();
int imgIdx = kinds.IndexOf(nameof(Image));
Assert.NotEqual(-1, imgIdx);
// The image must sit between the "Before " and " After" text runs, not appended after them.
Assert.True(imgIdx > 0 && imgIdx < kinds.Count - 1,
$"expected image between surrounding text, got element order: {string.Join(",", kinds)}");
}
[Fact]
public void AddHtml_WidthAndHeightAttributes_SetExplicitSizeFromPx()
{
var cell = NewCell();
var images = new Dictionary<string, string> { ["x"] = DataSrc };
cell.AddHtml("<div><img src=\"x\" width=\"96\" height=\"48\"></div>", images);
var para = Assert.IsType<Paragraph>(cell.Elements[0]);
var img = para.Elements.Cast<DocumentObject>().OfType<Image>().Single();
// 96px / 48px at 96dpi → 2.54cm / 1.27cm.
Assert.Equal(2.54, img.Width.Centimeter, 2);
Assert.Equal(1.27, img.Height.Centimeter, 2);
}
[Fact]
public void AddHtml_StyleWidthHeight_SetExplicitSizeFromPx()
{
var cell = NewCell();
var images = new Dictionary<string, string> { ["x"] = DataSrc };
cell.AddHtml("<div><img src=\"x\" style=\"width: 192px; height: 96px;\"></div>", images);
var para = Assert.IsType<Paragraph>(cell.Elements[0]);
var img = para.Elements.Cast<DocumentObject>().OfType<Image>().Single();
Assert.Equal(5.08, img.Width.Centimeter, 2);
Assert.Equal(2.54, img.Height.Centimeter, 2);
}
[Fact]
public void AddHtml_HugeHtmlSize_IsCappedToLayoutSafeMaximum()
{
var cell = NewCell();
var images = new Dictionary<string, string> { ["x"] = DataSrc };
// A huge (or hand-edited/malformed) size must never blow out the invoice table cell.
cell.AddHtml("<div><img src=\"x\" width=\"4000\" height=\"4000\"></div>", images);
var para = Assert.IsType<Paragraph>(cell.Elements[0]);
var img = para.Elements.Cast<DocumentObject>().OfType<Image>().Single();
Assert.True(img.Width.Centimeter <= 7.0);
Assert.True(img.Height.Centimeter <= 10.0);
}
[Fact]
public void AddHtml_NoSizeGiven_KeepsAspectRatioLocked()
{
var cell = NewCell();
var images = new Dictionary<string, string> { ["x"] = DataSrc };
cell.AddHtml("<div><img src=\"x\"></div>", images);
var para = Assert.IsType<Paragraph>(cell.Elements[0]);
var img = para.Elements.Cast<DocumentObject>().OfType<Image>().Single();
Assert.True(img.LockAspectRatio);
}
}
+5 -14
View File
@@ -11,7 +11,6 @@ using eRechnungLib.Profiles;
using Fuchs.intranet; using Fuchs.intranet;
using Fuchs.Services; using Fuchs.Services;
using MigraDoc.DocumentObjectModel; using MigraDoc.DocumentObjectModel;
using MigraDoc.Rendering;
using Microsoft.Extensions.Configuration; using Microsoft.Extensions.Configuration;
using PdfSharp.Pdf.IO; using PdfSharp.Pdf.IO;
using Xunit; using Xunit;
@@ -31,14 +30,6 @@ public class PdfPipelineTests
// ── Stage 1 helper: a "dummy" visual invoice PDF built purely with PdfSharp/MigraDoc ── // ── Stage 1 helper: a "dummy" visual invoice PDF built purely with PdfSharp/MigraDoc ──
private static byte[] BuildDummyPdfWithPdfSharp() private static byte[] BuildDummyPdfWithPdfSharp()
{ {
// Same font resolver the production render path installs (PdfSharp 6 no longer
// resolves system fonts on its own).
if (PdfSharp.Fonts.GlobalFontSettings.FontResolver is null ||
PdfSharp.Fonts.GlobalFontSettings.FontResolver.GetType() != typeof(OCORE_web_pdf.pdf.OCOREFontResolver))
{
PdfSharp.Fonts.GlobalFontSettings.FontResolver = new OCORE_web_pdf.pdf.OCOREFontResolver();
}
var doc = new Document(); var doc = new Document();
doc.Info.Title = "Dummy Rechnung"; doc.Info.Title = "Dummy Rechnung";
var normal = doc.Styles["Normal"]!; var normal = doc.Styles["Normal"]!;
@@ -50,11 +41,11 @@ public class PdfPipelineTests
sec.AddParagraph("Position 1: Beratungsleistung — 200,00 EUR netto"); sec.AddParagraph("Position 1: Beratungsleistung — 200,00 EUR netto");
sec.AddParagraph("Position 2: Entwicklung — 500,00 EUR netto"); sec.AddParagraph("Position 2: Entwicklung — 500,00 EUR netto");
var renderer = new PdfDocumentRenderer { Document = doc }; // Goes through FuchsPdf.DocToPdfBytesRaw so the font resolver is installed exactly the way
renderer.RenderDocument(); // (and exactly once, thread-safely) the production render path installs it — a second,
using var ms = new MemoryStream(); // separate check-then-set here previously raced PdfSharp's "must not change font resolver
renderer.PdfDocument.Save(ms, closeStream: false); // after it was once used" guard under parallel test execution.
return ms.ToArray(); return FuchsPdf.DocToPdfBytesRaw(doc);
} }
// ── Stage 3 helper: a minimal but EN 16931-complete domestic invoice model ── // ── Stage 3 helper: a minimal but EN 16931-complete domestic invoice model ──
+447 -179
View File
@@ -3,65 +3,71 @@ using System.Collections.Generic;
using System.Diagnostics.Metrics; using System.Diagnostics.Metrics;
using System.IO; using System.IO;
using System.Linq; using System.Linq;
using System.Net;
using System.Net.Http;
using System.Threading; using System.Threading;
using System.Threading.Tasks; using System.Threading.Tasks;
using Fuchs.Services; using Fuchs.Services;
using Microsoft.Extensions.Configuration; using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.Logging.Abstractions; using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Extensions.Options; using Microsoft.Extensions.Options;
using Moq;
using Newtonsoft.Json.Linq; using Newtonsoft.Json.Linq;
using OCORE.web.pwmailer;
using Xunit; using Xunit;
namespace Fuchs.Tests; namespace Fuchs.Tests;
/// <summary> /// <summary>
/// Tests for the outbound communication service. Cover both intentionally /// Tests for the outbound communication service as an adapter over the OCORE_web
/// succeeding (API 200) and intentionally failing (invalid email, disabled, /// <see cref="IPWMailerClientService"/> (ADR 0029). Cover both intentionally succeeding and
/// API 500) paths, plus the attachment payload contract and metric emission. /// intentionally failing paths (invalid email, disabled, API error, duplicate, refused by
/// pre-validation, attachment count not confirmed), the <see cref="PWMailerMessage"/> handed to
/// the client, the audit log written on every path, and metric emission. The HTTP contract itself
/// (JSON vs multipart, unnamed envelope field, attachmentCount) is the client's responsibility and
/// is tested in OCORE_webTests/comm/PWMailerClientServiceTests.cs.
/// ///
/// Fuchs_intranet is passed as null: it is only touched by the audit-log write, /// Fuchs_intranet is passed as null: it is only touched by the audit-log write,
/// which is wrapped in try/catch and therefore harmless in a unit test. /// which is wrapped in try/catch and therefore harmless in a unit test.
/// </summary> /// </summary>
public class ProcessWebComServiceTests public class ProcessWebComServiceTests
{ {
private const string FuchsMeterName = "Fuchs.Intranet";
// ── Test doubles ──────────────────────────────────────────────────────── // ── Test doubles ────────────────────────────────────────────────────────
private sealed class StubHandler : HttpMessageHandler private static PWMailerResult<PWMailerPushReceipt> Accepted(int received = 0, int processed = 0, string comStackId = "cs-1") =>
{ new() { StatusCode = 200, Value = new PWMailerPushReceipt(comStackId, received, processed), Attempts = 1, Operation = "push_com" };
private readonly HttpStatusCode _status;
private readonly string _body;
public string? LastRequestBody { get; private set; }
public int CallCount { get; private set; }
public StubHandler(HttpStatusCode status, string body = "OK") private static PWMailerResult<PWMailerPushReceipt> Failed(int status, string error) =>
new() { StatusCode = status, Error = error, Attempts = 1, Operation = "push_com" };
/// <summary>The client refused the call before sending (validation, options, token): status 0, no attempt.</summary>
private static PWMailerResult<PWMailerPushReceipt> Refused(string error) =>
new() { StatusCode = 0, Error = error, Attempts = 0, Operation = "push_com", Exception = new ArgumentException(error) };
private sealed class MailerStub
{ {
_status = status; public readonly Mock<IPWMailerClientService> Mock = new(MockBehavior.Loose);
_body = body; public readonly List<PWMailerMessage> Pushed = new();
public int CallCount => Pushed.Count;
public MailerStub(PWMailerResult<PWMailerPushReceipt>? push = null)
{
var result = push ?? Accepted();
Mock.Setup(m => m.PushAsync(It.IsAny<PWMailerMessage>(), It.IsAny<CancellationToken>()))
.Callback<PWMailerMessage, CancellationToken>((m, _) => Pushed.Add(m))
.ReturnsAsync(result);
} }
protected override async Task<HttpResponseMessage> SendAsync( /// <summary>Answers like a Mailer that processed every file it received.</summary>
HttpRequestMessage request, CancellationToken cancellationToken) public static MailerStub Echoing()
{ {
CallCount++; var stub = new MailerStub();
LastRequestBody = request.Content is null ? null : await request.Content.ReadAsStringAsync(cancellationToken); stub.Mock.Setup(m => m.PushAsync(It.IsAny<PWMailerMessage>(), It.IsAny<CancellationToken>()))
return new HttpResponseMessage(_status) { Content = new StringContent(_body) }; .Callback<PWMailerMessage, CancellationToken>((m, _) => stub.Pushed.Add(m))
.ReturnsAsync((PWMailerMessage m, CancellationToken _) => Accepted(m.Attachments.Count, m.Attachments.Count));
return stub;
} }
} }
private sealed class ThrowingHandler : HttpMessageHandler
{
protected override Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
=> throw new HttpRequestException("simulated transport failure");
}
private sealed class StubHttpClientFactory : IHttpClientFactory
{
private readonly HttpMessageHandler _handler;
public StubHttpClientFactory(HttpMessageHandler handler) => _handler = handler;
public HttpClient CreateClient(string name) => new(_handler, disposeHandler: false);
}
/// <summary> /// <summary>
/// Captures what would be written to <c>fds__logEmail</c> without hitting a database, by /// Captures what would be written to <c>fds__logEmail</c> without hitting a database, by
/// overriding the audit-log write. Lets tests assert that every send path is logged and that /// overriding the audit-log write. Lets tests assert that every send path is logged and that
@@ -70,145 +76,129 @@ public class ProcessWebComServiceTests
private sealed class CapturingComService : ProcessWebComService private sealed class CapturingComService : ProcessWebComService
{ {
public readonly List<(bool success, List<string> log)> Entries = new(); public readonly List<(bool success, List<string> log)> Entries = new();
public readonly List<string> Configs = new();
public readonly List<string> Guids = new();
public CapturingComService( public CapturingComService(IOptions<ProcessWebComSettings> settings, IOptions<FuchsEmailSettings> emailSettings,
IOptions<ProcessWebComSettings> settings, IPWMailerClientService mailer)
IOptions<FuchsEmailSettings> emailSettings, : base(NullLogger<ProcessWebComService>.Instance, intranet: null!, settings, emailSettings, mailer) { }
IHttpClientFactory httpClientFactory)
: base(NullLogger<ProcessWebComService>.Instance, intranet: null!, settings, emailSettings, httpClientFactory)
{
}
protected override Task WriteAuditLogAsync(string reference, string guid, string config, protected override Task WriteAuditLogAsync(string reference, string guid, string config,
DateTime sent, bool success, IEnumerable<string> errors) DateTime sent, bool success, IEnumerable<string> errors)
{ {
Entries.Add((success, errors.ToList())); Entries.Add((success, errors.ToList()));
Configs.Add(config);
Guids.Add(guid);
return Task.CompletedTask; return Task.CompletedTask;
} }
} }
private static CapturingComService CreateCapturing(HttpMessageHandler handler, bool enabled = true, string? overrideRecipient = null) private static IOptions<ProcessWebComSettings> Settings(bool enabled = true, string accountId = "acct", string token = "tok", string serverId = "") =>
Options.Create(new ProcessWebComSettings
{ {
var settings = Options.Create(new ProcessWebComSettings Enabled = enabled, BaseUrl = "https://mailer.test", AccountId = accountId, Token = token, ServerId = serverId
{
Enabled = enabled, BaseUrl = "https://mailer.test", AccountId = "acct", Token = "tok"
}); });
var emailSettings = Options.Create(new FuchsEmailSettings { OverrideRecipient = overrideRecipient });
return new CapturingComService(settings, emailSettings, new StubHttpClientFactory(handler));
}
private static ProcessWebComService CreateService(StubHandler handler, bool enabled = true, string? overrideRecipient = null) private static CapturingComService CreateCapturing(MailerStub mailer, bool enabled = true, string? overrideRecipient = null) =>
{ new(Settings(enabled), Options.Create(new FuchsEmailSettings { OverrideRecipient = overrideRecipient }), mailer.Mock.Object);
var settings = Options.Create(new ProcessWebComSettings
{ private static ProcessWebComService CreateService(MailerStub mailer, bool enabled = true, string? overrideRecipient = null) =>
Enabled = enabled, new(NullLogger<ProcessWebComService>.Instance, intranet: null!, Settings(enabled),
BaseUrl = "https://mailer.test", Options.Create(new FuchsEmailSettings { OverrideRecipient = overrideRecipient }), mailer.Mock.Object);
AccountId = "acct",
Token = "tok"
});
var emailSettings = Options.Create(new FuchsEmailSettings
{
OverrideRecipient = overrideRecipient
});
return new ProcessWebComService(
NullLogger<ProcessWebComService>.Instance,
intranet: null!,
settings,
emailSettings,
new StubHttpClientFactory(handler));
}
// ── Success path ───────────────────────────────────────────────────────── // ── Success path ─────────────────────────────────────────────────────────
[Fact] [Fact]
public async Task SendEmailAsync_ValidAndApiOk_ReturnsTrueAndPostsOnce() public async Task SendEmailAsync_ValidAndApiOk_ReturnsTrueAndPushesOnce()
{ {
var handler = new StubHandler(HttpStatusCode.OK); var mailer = new MailerStub();
var svc = CreateService(handler); var svc = CreateService(mailer);
bool result = await svc.SendEmailAsync("inv_1", "Subject", "<p>hi</p>", "kunde@example.de", "Kunde"); bool result = await svc.SendEmailAsync("inv_1", "Subject", "<p>hi</p>", "kunde@example.de", "Kunde");
Assert.True(result); Assert.True(result);
Assert.Equal(1, handler.CallCount); Assert.Equal(1, mailer.CallCount);
} }
// ── Failure paths ────────────────────────────────────────────────────────── // ── Failure paths ──────────────────────────────────────────────────────────
[Fact] [Fact]
public async Task SendEmailAsync_ApiError_ReturnsFalse() public async Task SendEmailAsync_ApiError_ReturnsFalse()
{ {
var handler = new StubHandler(HttpStatusCode.InternalServerError, "boom"); var mailer = new MailerStub(Failed(500, "boom"));
var svc = CreateService(handler); var svc = CreateService(mailer);
bool result = await svc.SendEmailAsync("inv_2", "Subject", "<p>hi</p>", "kunde@example.de", "Kunde"); bool result = await svc.SendEmailAsync("inv_2", "Subject", "<p>hi</p>", "kunde@example.de", "Kunde");
Assert.False(result); Assert.False(result);
Assert.Equal(1, handler.CallCount); Assert.Equal(1, mailer.CallCount);
} }
[Theory] [Theory]
[InlineData("not-an-email")] [InlineData("not-an-email")]
[InlineData("")] [InlineData("")]
[InlineData("missing@")] [InlineData("missing@")]
public async Task SendEmailAsync_InvalidEmail_ReturnsFalseWithoutCallingApi(string badEmail) public async Task SendEmailAsync_InvalidEmail_ReturnsFalseWithoutCallingMailer(string badEmail)
{ {
var handler = new StubHandler(HttpStatusCode.OK); var mailer = new MailerStub();
var svc = CreateService(handler); var svc = CreateService(mailer);
bool result = await svc.SendEmailAsync("inv_3", "Subject", "<p>hi</p>", badEmail, "Kunde"); bool result = await svc.SendEmailAsync("inv_3", "Subject", "<p>hi</p>", badEmail, "Kunde");
Assert.False(result); Assert.False(result);
Assert.Equal(0, handler.CallCount); // never reached the API Assert.Equal(0, mailer.CallCount);
} }
[Fact] [Fact]
public async Task SendEmailAsync_Disabled_ReturnsFalseWithoutCallingApi() public async Task SendEmailAsync_Disabled_ReturnsFalseWithoutCallingMailer()
{ {
var handler = new StubHandler(HttpStatusCode.OK); var mailer = new MailerStub();
var svc = CreateService(handler, enabled: false); var svc = CreateService(mailer, enabled: false);
bool result = await svc.SendEmailAsync("inv_4", "Subject", "<p>hi</p>", "kunde@example.de", "Kunde"); bool result = await svc.SendEmailAsync("inv_4", "Subject", "<p>hi</p>", "kunde@example.de", "Kunde");
Assert.False(result); Assert.False(result);
Assert.Equal(0, handler.CallCount); Assert.Equal(0, mailer.CallCount);
} }
// ── Audit logging: every attempt is logged; failures carry the service response ─ // ── Audit logging: every attempt is logged; failures carry the client's error ─
[Fact] [Fact]
public async Task SendEmailAsync_Success_LogsSuccessfulAttempt() public async Task SendEmailAsync_Success_LogsSuccessfulAttemptWithComStackId()
{ {
var svc = CreateCapturing(new StubHandler(HttpStatusCode.OK)); var svc = CreateCapturing(new MailerStub(Accepted(comStackId: "cs-42")));
await svc.SendEmailAsync("inv_log_ok", "S", "<p>x</p>", "kunde@example.de", "Kunde"); await svc.SendEmailAsync("inv_log_ok", "S", "<p>x</p>", "kunde@example.de", "Kunde");
var entry = Assert.Single(svc.Entries); var entry = Assert.Single(svc.Entries);
Assert.True(entry.success); Assert.True(entry.success);
Assert.Equal("cs-42", Assert.Single(svc.Guids)); // what getComStatus/getCom accept
} }
[Fact] [Fact]
public async Task SendEmailAsync_ApiError_LogsFailureIncludingServiceResponse() public async Task SendEmailAsync_ApiError_LogsFailureIncludingStatusAndError()
{ {
var svc = CreateCapturing(new StubHandler(HttpStatusCode.InternalServerError, "mailer rejected: quota exceeded")); var svc = CreateCapturing(new MailerStub(Failed(500, "mailer rejected: quota exceeded")));
bool result = await svc.SendEmailAsync("inv_log_api", "S", "<p>x</p>", "kunde@example.de", "Kunde"); bool result = await svc.SendEmailAsync("inv_log_api", "S", "<p>x</p>", "kunde@example.de", "Kunde");
Assert.False(result); Assert.False(result);
var entry = Assert.Single(svc.Entries); var entry = Assert.Single(svc.Entries);
Assert.False(entry.success); Assert.False(entry.success);
Assert.Contains(entry.log, l => l.Contains("mailer rejected: quota exceeded")); // raw service response Assert.Contains(entry.log, l => l.Contains("mailer rejected: quota exceeded") && l.Contains("500"));
Assert.Contains(entry.log, l => l.Contains("500")); // HTTP status
} }
[Fact] [Fact]
public async Task SendEmailAsync_TransportException_LogsFailureIncludingExceptionDetail() public async Task SendEmailAsync_ClientThrows_LogsFailureIncludingExceptionDetail()
{ {
var svc = CreateCapturing(new ThrowingHandler()); var mailer = new MailerStub();
mailer.Mock.Setup(m => m.PushAsync(It.IsAny<PWMailerMessage>(), It.IsAny<CancellationToken>()))
.ThrowsAsync(new InvalidOperationException("simulated client failure"));
var svc = CreateCapturing(mailer);
bool result = await svc.SendEmailAsync("inv_log_ex", "S", "<p>x</p>", "kunde@example.de", "Kunde"); bool result = await svc.SendEmailAsync("inv_log_ex", "S", "<p>x</p>", "kunde@example.de", "Kunde");
Assert.False(result); Assert.False(result);
var entry = Assert.Single(svc.Entries); var entry = Assert.Single(svc.Entries);
Assert.False(entry.success); Assert.False(entry.success);
Assert.Contains(entry.log, l => l.Contains("Exception while sending")); Assert.Contains(entry.log, l => l.Contains("Exception while sending") && l.Contains("simulated client failure"));
Assert.Contains(entry.log, l => l.Contains("simulated transport failure"));
} }
[Theory] [Theory]
@@ -216,7 +206,7 @@ public class ProcessWebComServiceTests
[InlineData("")] [InlineData("")]
public async Task SendEmailAsync_InvalidEmail_StillLogsFailedAttempt(string badEmail) public async Task SendEmailAsync_InvalidEmail_StillLogsFailedAttempt(string badEmail)
{ {
var svc = CreateCapturing(new StubHandler(HttpStatusCode.OK)); var svc = CreateCapturing(new MailerStub());
bool result = await svc.SendEmailAsync("inv_log_bad", "S", "<p>x</p>", badEmail, "Kunde"); bool result = await svc.SendEmailAsync("inv_log_bad", "S", "<p>x</p>", badEmail, "Kunde");
@@ -229,7 +219,7 @@ public class ProcessWebComServiceTests
[Fact] [Fact]
public async Task SendEmailAsync_Disabled_LogsFailedAttempt() public async Task SendEmailAsync_Disabled_LogsFailedAttempt()
{ {
var svc = CreateCapturing(new StubHandler(HttpStatusCode.OK), enabled: false); var svc = CreateCapturing(new MailerStub(), enabled: false);
await svc.SendEmailAsync("inv_log_dis", "S", "<p>x</p>", "kunde@example.de", "Kunde"); await svc.SendEmailAsync("inv_log_dis", "S", "<p>x</p>", "kunde@example.de", "Kunde");
@@ -267,31 +257,113 @@ public class ProcessWebComServiceTests
Assert.Equal(when, pl.Single(p => p.ParameterName == "@DateSent").Value); Assert.Equal(when, pl.Single(p => p.ParameterName == "@DateSent").Value);
} }
// ── Audit-log config: fds__emaillog.config must be the JSON fds_wgd_lastEmails parses ─
// Regression: the service logged config = "" on every path; OPENJSON('') throws, which took
// the "last emails" dashboard widget down and lost who/what/attachments from the audit trail.
[Fact]
public void BuildAuditConfig_FullInput_ProducesLegacyShapeWithoutBody()
{
var files = new Dictionary<string, byte[]> { ["Rechnung R2026-0119.pdf"] = [1, 2], ["x.xml"] = [3] };
string json = ProcessWebComService.BuildAuditConfig(
"SanitärFuchs - Rechnung", "buchhaltung@example.de", "Buchhaltung Erne", "chef@example.de", files.Keys);
var o = JObject.Parse(json);
Assert.Equal("SanitärFuchs - Rechnung", (string?)o["subject"]);
Assert.Equal("", (string?)o["from"]);
Assert.Equal(new[] { "\"Buchhaltung Erne\" <buchhaltung@example.de>" }, o["to"]!.Select(t => (string?)t));
Assert.Equal(new[] { "chef@example.de" }, o["cc"]!.Select(t => (string?)t));
Assert.Empty(o["bcc"]!);
Assert.Equal(new[] { "Rechnung R2026-0119.pdf", "x.xml" }, o["attachments"]!.Select(t => (string?)t));
Assert.Null(o["body"]); // never the HTML body
Assert.DoesNotContain("AQI=", json); // never attachment bytes (base64 of 1,2)
}
[Theory]
[InlineData(null, "a@b.de", "a@b.de")] // no name → bare address
[InlineData("", "a@b.de", "a@b.de")]
[InlineData(" ", "a@b.de", "a@b.de")]
[InlineData("Ann", "a@b.de", "\"Ann\" <a@b.de>")] // name → quoted display form the widget unwraps
[InlineData(" Ann ", "a@b.de", "\"Ann\" <a@b.de>")]
public void BuildAuditConfig_RecipientName_FormatsToArrayEntry(string? name, string email, string expected)
{
var o = JObject.Parse(ProcessWebComService.BuildAuditConfig("S", email, name, null, null));
Assert.Equal(new[] { expected }, o["to"]!.Select(t => (string?)t));
Assert.Empty(o["cc"]!);
Assert.Empty(o["attachments"]!);
}
[Theory]
[InlineData(null)]
[InlineData("")]
public void BuildAuditConfig_NullOrEmptySubject_StillValidJson(string? subject)
{
var o = JObject.Parse(ProcessWebComService.BuildAuditConfig(subject!, "a@b.de", null, null, null));
Assert.Equal("", (string?)o["subject"]);
}
public static IEnumerable<object[]> AllAuditPaths() =>
[
[ "success", 200, true, "kunde@example.de" ],
[ "api-error", 500, true, "kunde@example.de" ],
[ "disabled", 200, false, "kunde@example.de" ],
[ "invalid-email", 200, true, "not-an-email" ],
];
[Theory]
[MemberData(nameof(AllAuditPaths))]
public async Task SendEmailAsync_EveryAuditPath_LogsParsableConfigWithRecipientSubjectAndAttachments(
string path, int status, bool enabled, string email)
{
var mailer = status == 200 ? MailerStub.Echoing() : new MailerStub(Failed(status, "err"));
var svc = CreateCapturing(mailer, enabled: enabled);
var att = new Dictionary<string, byte[]> { ["Mahnung.pdf"] = [9, 9] };
await svc.SendEmailAsync("inv_cfg_" + path, "Subj " + path, "<p>x</p>", email, "Kunde", att);
string config = Assert.Single(svc.Configs);
var o = JObject.Parse(config); // was "" → OPENJSON error in fds_wgd_lastEmails
Assert.Equal("Subj " + path, (string?)o["subject"]);
Assert.Equal(new[] { $"\"Kunde\" <{email}>" }, o["to"]!.Select(t => (string?)t));
Assert.Equal(new[] { "Mahnung.pdf" }, o["attachments"]!.Select(t => (string?)t));
}
[Fact]
public async Task SendEmailAsync_OverrideRecipientSet_ConfigRecordsActualRecipientAndPrefixedSubject()
{
var svc = CreateCapturing(new MailerStub(), overrideRecipient: "dev-inbox@example.test");
await svc.SendEmailAsync("inv_cfg_ovr", "Subject", "<p>x</p>", "kunde@example.de", "Kunde", null, cc: "cc@example.de");
var o = JObject.Parse(Assert.Single(svc.Configs));
Assert.Equal(new[] { "\"Kunde\" <dev-inbox@example.test>" }, o["to"]!.Select(t => (string?)t)); // where it really went
Assert.Empty(o["cc"]!); // cc is dropped by the override
Assert.StartsWith("[DEV → kunde@example.de] ", (string?)o["subject"]); // original recipient stays traceable
}
// ── Dev/test recipient override safety net ───────────────────────────────── // ── Dev/test recipient override safety net ─────────────────────────────────
[Fact] [Fact]
public async Task SendEmailAsync_OverrideRecipientSet_RedirectsToOverrideAddress() public async Task SendEmailAsync_OverrideRecipientSet_RedirectsToOverrideAddress()
{ {
var handler = new StubHandler(HttpStatusCode.OK); var mailer = new MailerStub();
var svc = CreateService(handler, overrideRecipient: "dev-inbox@example.test"); var svc = CreateService(mailer, overrideRecipient: "dev-inbox@example.test");
bool result = await svc.SendEmailAsync("inv_ov1", "Subject", "<p>hi</p>", "realcustomer@example.de", "Kunde"); bool result = await svc.SendEmailAsync("inv_ov1", "Subject", "<p>hi</p>", "realcustomer@example.de", "Kunde");
Assert.True(result); Assert.True(result);
Assert.Equal(1, handler.CallCount); Assert.Equal("dev-inbox@example.test", Assert.Single(mailer.Pushed).To);
var json = JObject.Parse(handler.LastRequestBody!);
Assert.Equal("dev-inbox@example.test", json["communication"]!["to"]!.ToString());
} }
[Fact] [Fact]
public async Task SendEmailAsync_OverrideRecipientSet_SubjectRetainsOriginalRecipientForTraceability() public async Task SendEmailAsync_OverrideRecipientSet_SubjectRetainsOriginalRecipientForTraceability()
{ {
var handler = new StubHandler(HttpStatusCode.OK); var mailer = new MailerStub();
var svc = CreateService(handler, overrideRecipient: "dev-inbox@example.test"); var svc = CreateService(mailer, overrideRecipient: "dev-inbox@example.test");
await svc.SendEmailAsync("inv_ov2", "Rechnung 123", "<p>hi</p>", "realcustomer@example.de", "Kunde"); await svc.SendEmailAsync("inv_ov2", "Rechnung 123", "<p>hi</p>", "realcustomer@example.de", "Kunde");
var json = JObject.Parse(handler.LastRequestBody!); string subject = Assert.Single(mailer.Pushed).Subject;
string subject = json["communication"]!["subject"]!.ToString();
Assert.Contains("realcustomer@example.de", subject); Assert.Contains("realcustomer@example.de", subject);
Assert.Contains("Rechnung 123", subject); Assert.Contains("Rechnung 123", subject);
} }
@@ -299,84 +371,69 @@ public class ProcessWebComServiceTests
[Fact] [Fact]
public async Task SendEmailAsync_OverrideRecipientSet_InvalidOriginalAddressIsStillRedirected() public async Task SendEmailAsync_OverrideRecipientSet_InvalidOriginalAddressIsStillRedirected()
{ {
var handler = new StubHandler(HttpStatusCode.OK); var mailer = new MailerStub();
var svc = CreateService(handler, overrideRecipient: "dev-inbox@example.test"); var svc = CreateService(mailer, overrideRecipient: "dev-inbox@example.test");
bool result = await svc.SendEmailAsync("inv_ov3", "Subject", "<p>hi</p>", "not-an-email", "Kunde"); bool result = await svc.SendEmailAsync("inv_ov3", "Subject", "<p>hi</p>", "not-an-email", "Kunde");
Assert.True(result); Assert.True(result);
Assert.Equal(1, handler.CallCount); Assert.Equal("dev-inbox@example.test", Assert.Single(mailer.Pushed).To);
var json = JObject.Parse(handler.LastRequestBody!);
Assert.Equal("dev-inbox@example.test", json["communication"]!["to"]!.ToString());
} }
[Fact] [Fact]
public async Task SendEmailAsync_NoOverrideConfigured_SendsToOriginalRecipient() public async Task SendEmailAsync_NoOverrideConfigured_SendsToOriginalRecipient()
{ {
var handler = new StubHandler(HttpStatusCode.OK); var mailer = new MailerStub();
var svc = CreateService(handler); var svc = CreateService(mailer);
await svc.SendEmailAsync("inv_ov4", "Subject", "<p>hi</p>", "realcustomer@example.de", "Kunde"); await svc.SendEmailAsync("inv_ov4", "Subject", "<p>hi</p>", "realcustomer@example.de", "Kunde");
var json = JObject.Parse(handler.LastRequestBody!); Assert.Equal("realcustomer@example.de", Assert.Single(mailer.Pushed).To);
Assert.Equal("realcustomer@example.de", json["communication"]!["to"]!.ToString());
} }
// ── Cc recipient ("Zusatzempfänger") ───────────────────────────────────────── // ── Cc recipient ("Zusatzempfänger") ─────────────────────────────────────────
[Fact] [Fact]
public async Task SendEmailAsync_CcProvided_IsIncludedInPayload() public async Task SendEmailAsync_CcProvided_IsIncludedInMessage()
{ {
var handler = new StubHandler(HttpStatusCode.OK); var mailer = new MailerStub();
var svc = CreateService(handler); var svc = CreateService(mailer);
await svc.SendEmailAsync("inv_cc1", "Subject", "<p>hi</p>", "kunde@example.de", "Kunde", await svc.SendEmailAsync("inv_cc1", "Subject", "<p>hi</p>", "kunde@example.de", "Kunde", cc: "zusatz@example.de");
cc: "zusatz@example.de");
var json = JObject.Parse(handler.LastRequestBody!); Assert.Equal("zusatz@example.de", Assert.Single(mailer.Pushed).Cc);
Assert.Equal("zusatz@example.de", json["communication"]!["cc"]!.ToString());
} }
[Fact] [Fact]
public async Task SendEmailAsync_CcOmitted_IsNullInPayload() public async Task SendEmailAsync_CcOmitted_IsNullInMessage()
{ {
var handler = new StubHandler(HttpStatusCode.OK); var mailer = new MailerStub();
var svc = CreateService(handler); var svc = CreateService(mailer);
await svc.SendEmailAsync("inv_cc2", "Subject", "<p>hi</p>", "kunde@example.de", "Kunde"); await svc.SendEmailAsync("inv_cc2", "Subject", "<p>hi</p>", "kunde@example.de", "Kunde");
var json = JObject.Parse(handler.LastRequestBody!); Assert.Null(Assert.Single(mailer.Pushed).Cc);
Assert.Equal(JTokenType.Null, json["communication"]!["cc"]!.Type);
} }
[Fact] [Fact]
public async Task SendEmailAsync_OverrideRecipientSet_DropsCcEntirely() public async Task SendEmailAsync_OverrideRecipientSet_DropsCcEntirely()
{ {
var handler = new StubHandler(HttpStatusCode.OK); var mailer = new MailerStub();
var svc = CreateService(handler, overrideRecipient: "dev-inbox@example.test"); var svc = CreateService(mailer, overrideRecipient: "dev-inbox@example.test");
await svc.SendEmailAsync("inv_cc3", "Subject", "<p>hi</p>", "realcustomer@example.de", "Kunde", await svc.SendEmailAsync("inv_cc3", "Subject", "<p>hi</p>", "realcustomer@example.de", "Kunde", cc: "zusatz@example.de");
cc: "zusatz@example.de");
var json = JObject.Parse(handler.LastRequestBody!); var m = Assert.Single(mailer.Pushed);
Assert.Equal("dev-inbox@example.test", json["communication"]!["to"]!.ToString()); Assert.Equal("dev-inbox@example.test", m.To);
Assert.Equal(JTokenType.Null, json["communication"]!["cc"]!.Type); Assert.Null(m.Cc);
} }
// ── Override enforcement across every real appsettings*.json environment ─── // ── Override enforcement across every real appsettings*.json environment ───
// Loads the ACTUAL Fuchs/appsettings*.json files the same way ASP.NET Core // Loads the ACTUAL Fuchs/appsettings*.json files the same way ASP.NET Core layers them (base
// layers them (base file + optional environment-specific overlay). These files // file + optional environment-specific overlay). These files are copied into this test
// are copied into this test assembly's output directory via the Fuchs project // assembly's output directory via the Fuchs project reference. This proves the safety net holds
// reference (Microsoft.NET.Sdk.Web auto-includes appsettings*.json as Content // for whichever environment/appsettings file ends up "active" at runtime — not just a hand-typed
// with CopyToOutputDirectory). This proves the safety net holds for whichever // literal — so a future edit that silently breaks the override key or its value in any
// environment/appsettings file ends up "active" at runtime - not just a // appsettings*.json file would fail this test.
// hand-typed literal - so a future edit that silently breaks the override key
// or its value in any appsettings*.json file would fail this test.
//
// Note: this API has a single "communication.to" field - there is no distinct
// to/cc/bcc concept anywhere in the codebase (see ProcessWebComService.
// SendEmailAsync / communication.to). "to/cc/bcc cleared" is therefore fully
// satisfied by asserting that field no longer carries the original recipient
// once an override is configured.
public static IEnumerable<object[]> AppSettingsEnvironments() public static IEnumerable<object[]> AppSettingsEnvironments()
{ {
yield return new object[] { "" }; // base appsettings.json only ("Production"-like default) yield return new object[] { "" }; // base appsettings.json only ("Production"-like default)
@@ -403,8 +460,8 @@ public class ProcessWebComServiceTests
public async Task SendEmailAsync_OverrideRecipientFromRealAppsettings_ClearsRecipientWheneverConfigured(string environmentName) public async Task SendEmailAsync_OverrideRecipientFromRealAppsettings_ClearsRecipientWheneverConfigured(string environmentName)
{ {
string overrideRecipient = ResolveOverrideRecipientForEnvironment(environmentName); string overrideRecipient = ResolveOverrideRecipientForEnvironment(environmentName);
var handler = new StubHandler(HttpStatusCode.OK); var mailer = new MailerStub();
var svc = CreateService(handler, overrideRecipient: overrideRecipient); var svc = CreateService(mailer, overrideRecipient: overrideRecipient);
const string originalRecipient = "realcustomer@tenant-owner.example"; const string originalRecipient = "realcustomer@tenant-owner.example";
bool result = await svc.SendEmailAsync( bool result = await svc.SendEmailAsync(
@@ -412,68 +469,281 @@ public class ProcessWebComServiceTests
"Subject", "<p>hi</p>", originalRecipient, "Kunde"); "Subject", "<p>hi</p>", originalRecipient, "Kunde");
Assert.True(result); Assert.True(result);
Assert.Equal(1, handler.CallCount); string sentRecipient = Assert.Single(mailer.Pushed).To;
var json = JObject.Parse(handler.LastRequestBody!);
string sentRecipient = json["communication"]!["to"]!.ToString();
if (!string.IsNullOrWhiteSpace(overrideRecipient)) if (!string.IsNullOrWhiteSpace(overrideRecipient))
{ {
// Override configured for this environment: the real recipient (to/cc/bcc)
// must be fully cleared and replaced by the override - never leaked.
Assert.Equal(overrideRecipient, sentRecipient); Assert.Equal(overrideRecipient, sentRecipient);
Assert.NotEqual(originalRecipient, sentRecipient); Assert.NotEqual(originalRecipient, sentRecipient);
} }
else else
{ {
// No override configured for this environment: real recipient is used normally.
Assert.Equal(originalRecipient, sentRecipient); Assert.Equal(originalRecipient, sentRecipient);
} }
} }
// ── Attachment payload contract ──────────────────────────────────────────── // ── The message handed to the PWMailer client ─────────────────────────────
[Fact] [Fact]
public async Task SendEmailAsync_WithAttachment_EmbedsBase64InPayload() public async Task SendEmailAsync_MapsEnvelopeFieldsAndAttachmentsOntoPWMailerMessage()
{ {
var handler = new StubHandler(HttpStatusCode.OK); var mailer = MailerStub.Echoing();
var svc = CreateService(handler); var svc = CreateService(mailer);
byte[] pdf = { 1, 2, 3, 4 }; byte[] pdf = { 0x25, 0x50, 0x44, 0x46 };
var attachments = new Dictionary<string, byte[]> { ["Rechnung.pdf"] = pdf }; byte[] xml = { 0x3C, 0x3F };
var attachments = new Dictionary<string, byte[]> { ["Rechnung R2026-0119.pdf"] = pdf, ["zugferd.xml"] = xml };
bool result = await svc.SendEmailAsync("inv_5", "Subject", "<p>hi</p>", "kunde@example.de", "Kunde", attachments); bool result = await svc.SendEmailAsync("inv_map", "Subject", "<p>hi</p>", "kunde@example.de", "Kunde", attachments, cc: "cc@example.de");
Assert.True(result); Assert.True(result);
Assert.NotNull(handler.LastRequestBody); var m = Assert.Single(mailer.Pushed);
var json = JObject.Parse(handler.LastRequestBody!); Assert.Equal("e", m.ComType);
var att = (JArray)json["communication"]!["attachments"]!; Assert.Equal("inv_map", m.Reference);
Assert.Single(att); Assert.Equal("kunde@example.de", m.To);
Assert.Equal("Rechnung.pdf", att[0]!["filename"]!.ToString()); Assert.Equal("cc@example.de", m.Cc);
Assert.Equal("application/pdf", att[0]!["mimeType"]!.ToString()); Assert.Equal("Subject", m.Subject);
Assert.Equal(Convert.ToBase64String(pdf), att[0]!["contentBase64"]!.ToString()); Assert.StartsWith("<p>hi</p>", m.Body); // signature is appended after the body
Assert.Null(m.AttachmentCount); // null ⇒ client sends the real count
Assert.Equal(2, m.Attachments.Count);
Assert.Equal(pdf, m.Attachments.Single(a => a.FileName == "Rechnung R2026-0119.pdf").Bytes);
Assert.Equal(xml, m.Attachments.Single(a => a.FileName == "zugferd.xml").Bytes);
}
// ── Response checks ────────────────────────────────────────────────────────
[Theory]
[InlineData(1, 0)]
[InlineData(0, 0)]
public async Task SendEmailAsync_AttachmentsNotConfirmedProcessed_ReturnsFalseAndAuditsCounts(int received, int processed)
{
// A 200 is not enough when files were sent: the Mailer must report every one as processed.
var svc = CreateCapturing(new MailerStub(Accepted(received, processed, "cs-9")));
var att = new Dictionary<string, byte[]> { ["Rechnung.pdf"] = [1, 2, 3] };
bool result = await svc.SendEmailAsync("inv_mismatch", "S", "<p>x</p>", "kunde@example.de", "Kunde", att);
Assert.False(result);
var entry = Assert.Single(svc.Entries);
Assert.False(entry.success);
Assert.Contains(entry.log, l => l.Contains("not all 1 attachment(s)")
&& l.Contains($"attachmentsReceived={received}") && l.Contains($"attachmentsProcessed={processed}"));
Assert.Equal("cs-9", Assert.Single(svc.Guids)); // still traceable at the Mailer
}
[Fact]
public async Task SendEmailAsync_AttachmentsMismatch_IncrementsFailedCounterWithReason()
{
string? reason = null;
using var listener = new MeterListener
{
InstrumentPublished = (inst, l) =>
{
if (inst.Meter.Name == FuchsMeterName && inst.Name == "fuchs.emails.failed")
l.EnableMeasurementEvents(inst);
}
};
listener.SetMeasurementEventCallback<long>((_, _, tags, _) =>
{
foreach (var t in tags) if (t.Key == "reason") reason = t.Value?.ToString();
});
listener.Start();
var svc = CreateService(new MailerStub(Accepted(1, 0)));
await svc.SendEmailAsync("inv_mm_metric", "S", "<p>x</p>", "kunde@example.de", "Kunde",
new Dictionary<string, byte[]> { ["a.pdf"] = [1] });
Assert.Equal("attachments-mismatch", reason);
}
[Fact]
public async Task SendEmailAsync_Conflict409_IsSuccessBecauseThePendingDuplicateWillBeSent()
{
// A resend of an identical message while the first is still pending: the Mailer keeps the
// first entry and delivers it — the caller (e.g. req/resend, or a double click) must see a
// successful send, with the deduplication recorded in the audit log.
var svc = CreateCapturing(new MailerStub(Failed(409, "duplicate of pending entry")));
bool result = await svc.SendEmailAsync("inv_dup", "S", "<p>x</p>", "kunde@example.de", "Kunde");
Assert.True(result);
var entry = Assert.Single(svc.Entries);
Assert.True(entry.success);
Assert.Contains(entry.log, l => l.Contains("still-pending") && l.Contains("409") && l.Contains("duplicate of pending entry"));
}
[Fact]
public async Task SendEmailAsync_RefusedByClientPreValidation_ReturnsFalseAndAuditsEachRule()
{
// The client refuses before sending (status 0, no attempt) with "; "-joined rule messages —
// e.g. Fuchs' attachment limits (FuchsMailerClient) or the contract's required fields.
var svc = CreateCapturing(new MailerStub(Refused("Attachment 'big.pdf' is 11,000,000 bytes (max 10,485,760); attachment 'x' has no file extension")));
bool result = await svc.SendEmailAsync("inv_refused", "S", "<p>x</p>", "kunde@example.de", "Kunde",
new Dictionary<string, byte[]> { ["big.pdf"] = [1] });
Assert.False(result);
var entry = Assert.Single(svc.Entries);
Assert.False(entry.success);
Assert.Equal(2, entry.log.Count);
Assert.Contains(entry.log, l => l.StartsWith("Attachment 'big.pdf'"));
Assert.Contains(entry.log, l => l.Contains("no file extension"));
}
[Fact]
public async Task SendEmailAsync_RefusedBecauseClientNotUsable_ReturnsFalseWithClientConfigReason()
{
// Same shape as a validation refusal (status 0, no attempt) but no ArgumentException: the
// client could not resolve its token / options. Must not be reported as caller validation.
string? reason = null;
using var listener = new MeterListener
{
InstrumentPublished = (inst, l) =>
{
if (inst.Meter.Name == FuchsMeterName && inst.Name == "fuchs.emails.failed")
l.EnableMeasurementEvents(inst);
}
};
listener.SetMeasurementEventCallback<long>((_, _, tags, _) =>
{
foreach (var t in tags) if (t.Key == "reason") reason = t.Value?.ToString();
});
listener.Start();
var svc = CreateCapturing(new MailerStub(new PWMailerResult<PWMailerPushReceipt>
{
StatusCode = 0, Attempts = 0, Operation = "push_com",
Error = "PWMailer token could not be loaded from Key Vault secret 'Fuchs--Mailer--Token': timeout"
}));
bool result = await svc.SendEmailAsync("inv_cfg", "S", "<p>x</p>", "kunde@example.de", "Kunde");
Assert.False(result);
Assert.Equal("client-config", reason);
var entry = Assert.Single(svc.Entries);
Assert.False(entry.success);
Assert.Contains(entry.log, l => l.StartsWith("Mailer client not usable") && l.Contains("Key Vault"));
} }
// ── SMS ──────────────────────────────────────────────────────────────────── // ── SMS ────────────────────────────────────────────────────────────────────
[Fact] [Fact]
public async Task SendSmsAsync_ValidAndApiOk_ReturnsTrue() public async Task SendSmsAsync_ValidAndApiOk_ReturnsTrueAndPushesComTypeS()
{ {
var handler = new StubHandler(HttpStatusCode.OK); var mailer = new MailerStub();
var svc = CreateService(handler); var svc = CreateService(mailer);
bool result = await svc.SendSmsAsync("01700000000", "Code 1234"); bool result = await svc.SendSmsAsync("01700000000", "Code 1234");
Assert.True(result); Assert.True(result);
Assert.Equal(1, handler.CallCount); var m = Assert.Single(mailer.Pushed);
Assert.Equal("s", m.ComType);
Assert.Equal("01700000000", m.To);
Assert.Equal("Code 1234", m.Body);
} }
[Fact] [Fact]
public async Task SendSmsAsync_EmptyMobile_ReturnsFalseWithoutCallingApi() public async Task SendSmsAsync_EmptyMobile_ReturnsFalseWithoutCallingMailer()
{ {
var handler = new StubHandler(HttpStatusCode.OK); var mailer = new MailerStub();
var svc = CreateService(handler); var svc = CreateService(mailer);
bool result = await svc.SendSmsAsync("", "Code 1234"); bool result = await svc.SendSmsAsync("", "Code 1234");
Assert.False(result); Assert.False(result);
Assert.Equal(0, handler.CallCount); Assert.Equal(0, mailer.CallCount);
}
// ── Credential verification (client VerifyAsync) ────────────────────────────
private static PWMailerResult<PWMailerVerifyResult> VerifyResult(bool ok, string account = "acc-123", string? serverId = null) =>
new() { StatusCode = 200, Value = new PWMailerVerifyResult(ok, account, serverId), Attempts = 1, Operation = "verify" };
private static ProcessWebComService VerifyService(Mock<IPWMailerClientService> mailer, string accountId = "acct", string token = "tok", string serverId = "") =>
new(NullLogger<ProcessWebComService>.Instance, intranet: null!, Settings(true, accountId, token, serverId),
Options.Create(new FuchsEmailSettings()), mailer.Object);
[Fact]
public async Task VerifyCredentialsAsync_MailerConfirms_ReturnsOkWithAccount()
{
var mailer = new Mock<IPWMailerClientService>();
mailer.Setup(m => m.VerifyAsync(null, It.IsAny<CancellationToken>())).ReturnsAsync(VerifyResult(true));
var result = await VerifyService(mailer).VerifyCredentialsAsync();
Assert.True(result.Ok);
Assert.Equal("acc-123", result.Account);
mailer.Verify(m => m.VerifyAsync(null, It.IsAny<CancellationToken>()), Times.Once);
}
[Fact]
public async Task VerifyCredentialsAsync_ServerIdConfigured_IsSentAndEchoedOnSuccess()
{
var mailer = new Mock<IPWMailerClientService>();
mailer.Setup(m => m.VerifyAsync("srv-1", It.IsAny<CancellationToken>())).ReturnsAsync(VerifyResult(true, serverId: "srv-1"));
var result = await VerifyService(mailer, serverId: "srv-1").VerifyCredentialsAsync();
Assert.True(result.Ok);
Assert.Equal("srv-1", result.ServerId);
}
[Fact]
public async Task VerifyCredentialsAsync_ServerIdNotOwnedByAccount_ReturnsNotOkWithServerIdDetail()
{
var mailer = new Mock<IPWMailerClientService>();
mailer.Setup(m => m.VerifyAsync("srv-wrong", It.IsAny<CancellationToken>())).ReturnsAsync(VerifyResult(false, serverId: "srv-wrong"));
var result = await VerifyService(mailer, serverId: "srv-wrong").VerifyCredentialsAsync();
Assert.False(result.Ok);
Assert.Contains("srv-wrong", result.Detail);
}
[Fact]
public async Task VerifyCredentialsAsync_MailerReturnsOkFalse_ReturnsNotOk()
{
var mailer = new Mock<IPWMailerClientService>();
mailer.Setup(m => m.VerifyAsync(null, It.IsAny<CancellationToken>())).ReturnsAsync(VerifyResult(false));
var result = await VerifyService(mailer).VerifyCredentialsAsync();
Assert.False(result.Ok);
Assert.Contains("ungültige Zugangsdaten", result.Detail);
}
[Fact]
public async Task VerifyCredentialsAsync_Http401_ReturnsNotOkWithDetail()
{
var mailer = new Mock<IPWMailerClientService>();
mailer.Setup(m => m.VerifyAsync(null, It.IsAny<CancellationToken>()))
.ReturnsAsync(new PWMailerResult<PWMailerVerifyResult> { StatusCode = 401, Error = "not authenticated", Attempts = 1 });
var result = await VerifyService(mailer, token: "wrong").VerifyCredentialsAsync();
Assert.False(result.Ok);
Assert.Contains("401", result.Detail);
Assert.Contains("not authenticated", result.Detail);
}
[Fact]
public async Task VerifyCredentialsAsync_TransportError_ReturnsNotOkWithClientError()
{
var mailer = new Mock<IPWMailerClientService>();
mailer.Setup(m => m.VerifyAsync(null, It.IsAny<CancellationToken>()))
.ReturnsAsync(new PWMailerResult<PWMailerVerifyResult> { StatusCode = 0, Error = "simulated transport failure", Attempts = 3, TimedOut = false });
var result = await VerifyService(mailer).VerifyCredentialsAsync();
Assert.False(result.Ok);
Assert.Contains("simulated transport failure", result.Detail);
}
[Theory]
[InlineData("", "tok")]
[InlineData("acct", "")]
public async Task VerifyCredentialsAsync_CredentialsNotConfigured_ReturnsNotOkWithoutCallingClient(string accountId, string token)
{
var mailer = new Mock<IPWMailerClientService>();
var result = await VerifyService(mailer, accountId, token).VerifyCredentialsAsync();
Assert.False(result.Ok);
mailer.Verify(m => m.VerifyAsync(It.IsAny<string?>(), It.IsAny<CancellationToken>()), Times.Never);
} }
// ── Metric emission (performance indicator) ──────────────────────────────── // ── Metric emission (performance indicator) ────────────────────────────────
@@ -492,11 +762,9 @@ public class ProcessWebComServiceTests
listener.SetMeasurementEventCallback<long>((_, value, _, _) => Interlocked.Add(ref delta, value)); listener.SetMeasurementEventCallback<long>((_, value, _, _) => Interlocked.Add(ref delta, value));
listener.Start(); listener.Start();
var svc = CreateService(new StubHandler(HttpStatusCode.OK)); var svc = CreateService(new MailerStub());
await svc.SendEmailAsync("inv_metric", "S", "<p>x</p>", "kunde@example.de", "Kunde"); await svc.SendEmailAsync("inv_metric", "S", "<p>x</p>", "kunde@example.de", "Kunde");
Assert.True(delta >= 1, "fuchs.emails.sent counter should have been incremented on a successful send."); Assert.True(delta >= 1, "fuchs.emails.sent counter should have been incremented on a successful send.");
} }
private const string FuchsMeterName = "Fuchs.Intranet";
} }
+4 -2
View File
@@ -238,7 +238,8 @@ public class ReminderDraftServiceTests
var result = await svc.FlushToDbAsync(s.Token, "user1", null!); var result = await svc.FlushToDbAsync(s.Token, "user1", null!);
Assert.NotNull(result); Assert.NotNull(result);
Assert.Equal("REM42", result!.Id); Assert.Equal("REM42", result!.Data.Id);
Assert.False(result.Updated);
Assert.False(rem.LastChange); // new draft (no prior RemId) → create Assert.False(rem.LastChange); // new draft (no prior RemId) → create
Assert.Equal("REM42", svc.Get(s.Token)!.RemId); Assert.Equal("REM42", svc.Get(s.Token)!.RemId);
// the email/subject the editor set must reach registration // the email/subject the editor set must reach registration
@@ -255,9 +256,10 @@ public class ReminderDraftServiceTests
payload["remid"] = "REM7"; payload["remid"] = "REM7";
var s = svc.OpenFromPayload(payload, "user1"); var s = svc.OpenFromPayload(payload, "user1");
await svc.FlushToDbAsync(s.Token, "user1", null!); var result = await svc.FlushToDbAsync(s.Token, "user1", null!);
Assert.True(rem.LastChange); // prior RemId → update path Assert.True(rem.LastChange); // prior RemId → update path
Assert.True(result!.Updated);
} }
[Fact] [Fact]
+50
View File
@@ -0,0 +1,50 @@
using System;
using Fuchs.Services;
using Xunit;
namespace Fuchs.Tests;
/// <summary>
/// The reminder save batch (<see cref="ReminderService.BuildRegisterReminderSql"/>): a new draft is
/// created, a re-saved one is updated in place via <c>fds__setReminder</c> — previously the update
/// path ran an empty batch, so "Zwischenspeichern" of an already-registered reminder draft silently
/// saved nothing. Either proc leaving <c>@Id</c> NULL must surface as an error, not as an empty row.
/// </summary>
public class ReminderRegisterSqlTests
{
[Theory]
[InlineData(false, "[dbo].[fds__createReminder] @InvId,", "[dbo].[fds__setReminder]")]
[InlineData(true, "[dbo].[fds__setReminder] @RemId,", "[dbo].[fds__createReminder]")]
public void BuildRegisterReminderSql_ExactlyOneProcPerMode(bool change, string expectedProc, string otherProc)
{
string sql = ReminderService.BuildRegisterReminderSql(change);
Assert.Contains(expectedProc, sql);
Assert.DoesNotContain(otherProc, sql);
Assert.Equal(change, sql.Contains("@RemId"));
Assert.Equal(!change, sql.Contains("@InvId"));
}
[Theory]
[InlineData(false)]
[InlineData(true)]
public void BuildRegisterReminderSql_GuardFollowsTheProc(bool change)
{
string sql = ReminderService.BuildRegisterReminderSql(change);
int proc = sql.IndexOf("EXECUTE [dbo].[fds__", StringComparison.Ordinal);
int guard = sql.IndexOf("IF @Id IS NULL THROW 50001", StringComparison.Ordinal);
Assert.True(proc >= 0 && guard > proc, sql);
Assert.StartsWith("DECLARE @Id varchar(10);", sql);
}
[Theory]
[InlineData(false)]
[InlineData(true)]
public void BuildRegisterReminderSql_EveryStatementIsSemicolonTerminated(bool change)
{
// THROW requires the preceding statement to be terminated with ';'.
foreach (var line in ReminderService.BuildRegisterReminderSql(change).Split('\n'))
Assert.EndsWith(";", line.TrimEnd());
}
}
@@ -0,0 +1,125 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using Fuchs.intranet;
using Fuchs.Notifications;
using Fuchs.Services;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.Logging;
using MigraDoc.DocumentObjectModel;
using Moq;
using OCORE.security;
using Xunit;
namespace Fuchs.Tests;
/// <summary>
/// Reminder counterpart of <see cref="InvoiceServiceStoreFileTests"/>: every failure branch of
/// <see cref="ReminderService.StoreReminderDocumentFileAsync"/> must be logged individually and
/// must not reach the blob upload or the ReminderFileCreated event (ADR 0025).
/// </summary>
public class ReminderServiceStoreFileTests
{
private sealed class RecordingLogger : ILogger<ReminderService>
{
public sealed record Entry(LogLevel Level, Exception? Exception, string Message);
public readonly List<Entry> Entries = new();
public IDisposable? BeginScope<TState>(TState state) where TState : notnull => null;
public bool IsEnabled(LogLevel logLevel) => true;
public void Log<TState>(LogLevel logLevel, EventId eventId, TState state, Exception? exception,
Func<TState, Exception?, string> formatter) =>
Entries.Add(new Entry(logLevel, exception, formatter(state, exception)));
}
private const string UnreachableConnectionString =
"Server=127.0.0.1,1;Database=fuchs;User Id=u;Password=p;Connect Timeout=1;TrustServerCertificate=True";
private static (ReminderService svc, RecordingLogger log, Mock<IBlobStorageService> blob, Mock<IEventService> events)
MakeService(Mock<IPdfService> pdf, string? connectionString = null)
{
var log = new RecordingLogger();
var blob = new Mock<IBlobStorageService>();
var events = new Mock<IEventService>();
var config = new ConfigurationBuilder().AddInMemoryCollection(new Dictionary<string, string?>
{
["ConnectionStrings:fuchs_fds_ConnectionString"] = connectionString
}).Build();
var svc = new ReminderService(new Fuchs_intranet(config), pdf.Object, blob.Object, events.Object, log);
return (svc, log, blob, events);
}
private static FdsReminderData Reminder() => new()
{
ReminderRegistration = new OCORE.OCORE_dictionaries.GenericObjectDictionary(new Dictionary<string, object?>
{
["Id"] = "M1ABC",
["subject"] = "Zahlungserinnerung",
["IsFinal"] = true
}),
IsDraft = false
};
private static Mock<IPdfService> PdfReturning(byte[] bytes)
{
var pdf = new Mock<IPdfService>();
pdf.Setup(p => p.WriteLetterAsync(It.IsAny<FuchsPdf.FdsTextBlocks>(), It.IsAny<bool>()))
.ReturnsAsync(new Document());
pdf.Setup(p => p.DocToPdfBytes(It.IsAny<Document>())).Returns(bytes);
return pdf;
}
private static void AssertNothingPersisted(Mock<IBlobStorageService> blob, Mock<IEventService> events)
{
blob.Verify(b => b.UploadReminderPdfAsync(It.IsAny<string>(), It.IsAny<string>(), It.IsAny<byte[]>(),
It.IsAny<IReadOnlyDictionary<string, object?>>(), It.IsAny<CancellationToken>()), Times.Never);
events.Verify(e => e.ReminderFileCreatedAsync(It.IsAny<FdsReminderData>(), It.IsAny<string>(), It.IsAny<string>()),
Times.Never);
}
[Fact]
public async Task StoreReminderDocumentFileAsync_RenderProducesZeroBytes_ReturnsEmptyAndLogsError()
{
var (svc, log, blob, events) = MakeService(PdfReturning(Array.Empty<byte>()));
var result = await svc.StoreReminderDocumentFileAsync(Reminder(), false, "user1", new DatabaseSecurity("user1", null));
Assert.Empty(result);
var entry = Assert.Single(log.Entries, e => e.Level == LogLevel.Error);
Assert.Null(entry.Exception);
Assert.Contains("produced 0 bytes", entry.Message);
Assert.Contains("M1ABC", entry.Message);
AssertNothingPersisted(blob, events);
}
[Fact]
public async Task StoreReminderDocumentFileAsync_RenderThrows_ReturnsEmptyAndLogsExceptionOnce()
{
var pdf = new Mock<IPdfService>();
var boom = new InvalidOperationException("render exploded");
pdf.Setup(p => p.WriteLetterAsync(It.IsAny<FuchsPdf.FdsTextBlocks>(), It.IsAny<bool>())).ThrowsAsync(boom);
var (svc, log, blob, events) = MakeService(pdf);
var result = await svc.StoreReminderDocumentFileAsync(Reminder(), false, "user1", new DatabaseSecurity("user1", null));
Assert.Empty(result);
var entry = Assert.Single(log.Entries, e => e.Level == LogLevel.Error);
Assert.Same(boom, entry.Exception);
Assert.Contains("render failed", entry.Message);
AssertNothingPersisted(blob, events);
}
[Fact]
public async Task StoreReminderDocumentFileAsync_RenderOkButDbStoreFails_ReturnsEmptyAndSkipsBlob()
{
var (svc, log, blob, events) = MakeService(PdfReturning(new byte[] { 0x25, 0x50, 0x44, 0x46 }), UnreachableConnectionString);
var result = await svc.StoreReminderDocumentFileAsync(Reminder(), false, "user1", new DatabaseSecurity("user1", null));
Assert.Empty(result);
Assert.DoesNotContain(log.Entries, e => e.Message.Contains("produced 0 bytes"));
Assert.DoesNotContain(log.Entries, e => e.Message.Contains("render failed"));
AssertNothingPersisted(blob, events);
}
}
+84
View File
@@ -0,0 +1,84 @@
using Fuchs.Controllers;
using Xunit;
namespace Fuchs.Tests;
/// <summary>
/// <see cref="IntranetController.SafeDocumentFileName"/> names a regenerated invoice document
/// (inv/rdoc, ADR 0025). DocumentName comes from the user-typed invoice title, so it must survive
/// path separators and editor markup without truncating or breaking the download filename.
/// </summary>
public class ReportDocFileNameTests
{
[Theory]
[InlineData("Rechnung R2026-0258.pdf", "Rechnung R2026-0258.pdf")]
[InlineData("Wartung 2025/26 R2026-0001.pdf", "Wartung 2025_26 R2026-0001.pdf")]
[InlineData("Angebot A\\B: \"x\" <y>|z?*.pdf", "Angebot A_B_ _x_ _y__z__.pdf")]
[InlineData("<p>Rechnung R2026-0002.pdf</p>", "Rechnung R2026-0002.pdf")]
[InlineData(" Rechnung R2026-0003.pdf ", "Rechnung R2026-0003.pdf")]
public void SafeDocumentFileName_ReplacesInvalidCharsAndStripsMarkup(string documentName, string expected)
{
Assert.Equal(expected, IntranetController.SafeDocumentFileName(documentName, "Rechnung_X.pdf"));
}
[Theory]
[InlineData(null)]
[InlineData("")]
[InlineData(" ")]
[InlineData("<p></p>")]
[InlineData("<p> </p>")]
public void SafeDocumentFileName_EmptyDocumentName_UsesFallback(string? documentName)
{
Assert.Equal("Rechnung_W7VAL.pdf", IntranetController.SafeDocumentFileName(documentName!, "Rechnung_W7VAL.pdf"));
}
/// <summary>
/// The stored-document path (<c>FdsMfr.GetReportDoc</c>) replaced exactly these characters
/// before the sanitiser was shared: <c>= &gt; | ! &lt; /</c>. The shared helper must still
/// produce the identical result for them — unifying the two paths may not loosen either.
/// </summary>
[Theory]
[InlineData("a>b|c!d<e/f=g.pdf", "a_b_c_d_e_f_g.pdf")]
[InlineData("<p>Summe=100!.pdf</p>", "Summe_100_.pdf")]
public void SafeFileName_KeepsEveryReplacementTheStoredDocumentPathMade(string documentName, string expected)
{
Assert.Equal(expected, fds.FdsDocumentNames.SafeFileName(documentName, "X.pdf"));
}
/// <summary>
/// Characters the old stored-document path did NOT catch but Windows rejects — the reason
/// the sanitiser was unified (code review on ADR 0025): a stored and a regenerated document
/// must never diverge in filename safety.
/// </summary>
[Theory]
[InlineData("Angebot A\\B.pdf", "Angebot A_B.pdf")]
[InlineData("Re: 2026.pdf", "Re_ 2026.pdf")]
[InlineData("x*y?z\".pdf", "x_y_z_.pdf")]
[InlineData("tab\there.pdf", "tab_here.pdf")]
public void SafeFileName_ReplacesInvalidCharsTheStoredDocumentPathMissed(string documentName, string expected)
{
Assert.Equal(expected, fds.FdsDocumentNames.SafeFileName(documentName, "X.pdf"));
}
[Theory]
[InlineData("Rechnung R2026-0258.pdf")]
[InlineData("Wartung 2025/26 R2026-0001.pdf")]
[InlineData("a>b|c!d<e/f=g.pdf")]
[InlineData("Angebot A\\B: \"x\" <y>|z?*.pdf")]
[InlineData("<p></p>")]
[InlineData(null)]
public void SafeDocumentFileName_MatchesSharedSanitiserForEveryInput(string? documentName)
{
Assert.Equal(
fds.FdsDocumentNames.SafeFileName(documentName, "Rechnung_X.pdf"),
IntranetController.SafeDocumentFileName(documentName!, "Rechnung_X.pdf"));
}
[Fact]
public void SafeFileName_ReturnsFallbackVerbatim_WhenNothingUsableRemains()
{
// The fallback is the caller's responsibility and is not sanitised or second-guessed.
Assert.Equal("W7VAL", fds.FdsDocumentNames.SafeFileName("<p></p>", "W7VAL"));
Assert.Equal("", fds.FdsDocumentNames.SafeFileName("", ""));
}
}
@@ -0,0 +1,76 @@
using System.Security.Claims;
using Fuchs.intranet;
using Fuchs.Logging;
using Microsoft.AspNetCore.Http;
using Xunit;
namespace Fuchs.Tests;
/// <summary>
/// Covers <see cref="RequestLogScopeMiddleware.BuildScope"/>: the per-request scope that attributes
/// every log line (including ones written deep inside OCORE) to a request id, path, and — once
/// authenticated — the <c>fuchs:useraccount_id</c> claim, feeding both the generic serialized
/// context and (via the "authuser" key specifically) <c>LogEntry.AuthUser</c>. See ADR 0024.
/// </summary>
public class RequestLogScopeMiddlewareTests
{
private static DefaultHttpContext ContextWithPath(string path)
{
var ctx = new DefaultHttpContext();
ctx.Request.Path = path;
return ctx;
}
[Fact]
public void BuildScope_UnauthenticatedRequest_CarriesRequestIdAndPath_NoAuthUserKey()
{
var ctx = ContextWithPath("/do/inv/dpreview");
ctx.TraceIdentifier = "trace-123";
var scope = RequestLogScopeMiddleware.BuildScope(ctx);
Assert.Equal("trace-123", scope["requestId"]);
Assert.Equal("/do/inv/dpreview", scope["path"]);
Assert.False(scope.ContainsKey("authuser"));
}
[Fact]
public void BuildScope_AuthenticatedRequest_IncludesAuthUserFromClaim()
{
var ctx = ContextWithPath("/do/inv/dpreview");
var identity = new ClaimsIdentity(new[]
{
new Claim(FuchsUserIdentity.ClaimUserAccountId, "acct-42"),
}, "fuchs_intranet");
ctx.User = new ClaimsPrincipal(identity);
var scope = RequestLogScopeMiddleware.BuildScope(ctx);
Assert.Equal("acct-42", scope["authuser"]);
}
[Fact]
public void BuildScope_AuthenticatedPrincipalWithEmptyClaim_OmitsAuthUserKey()
{
var ctx = ContextWithPath("/do/inv/dpreview");
var identity = new ClaimsIdentity(new[]
{
new Claim(FuchsUserIdentity.ClaimUserAccountId, ""),
}, "fuchs_intranet");
ctx.User = new ClaimsPrincipal(identity);
var scope = RequestLogScopeMiddleware.BuildScope(ctx);
Assert.False(scope.ContainsKey("authuser"));
}
[Fact]
public void BuildScope_RootPath_UsesEmptyStringNotNull()
{
var ctx = new DefaultHttpContext();
var scope = RequestLogScopeMiddleware.BuildScope(ctx);
Assert.Equal("", scope["path"]);
}
}
+298
View File
@@ -0,0 +1,298 @@
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Text.RegularExpressions;
using Xunit;
namespace Fuchs.Tests;
/// <summary>
/// The C# ↔ T-SQL contract, checked against the <c>Fuchs_Database</c> SSDT project (ADR 0036).
/// Every <c>[dbo].[…]</c> object named in a C# string must exist in the project, every
/// <c>EXECUTE</c> must bind against the procedure's declared parameters, and every scalar/table
/// function call must pass the declared number of arguments. None of this is visible to the
/// compiler or to a unit test that stubs the database — it used to fail only at runtime
/// (e.g. <c>inv/datev</c> passing three positional arguments to a four-parameter proc).
/// The SSDT build covers the SQL side of the same contract (unresolved/ambiguous columns).
/// </summary>
public class SqlContractTests
{
internal sealed record SqlObject(string Name, string Kind, IReadOnlyList<(string Name, bool HasDefault)> Parameters);
internal sealed record SqlReference(string File, int Line, string Name, bool IsExecute, bool IsCall, string Args);
private const char JoinedBreak = '\u0001';
private static readonly string[] ScannedProjects = ["Fuchs", "Fuchs_DataService"];
// Signature FdsMfrClient.Update__entitytable uses for its dynamically named [mfr__updt__<table>] call.
private static readonly string[] MfrUpdtSignature = ["@tblname", "@referencetable", "@tgtid"];
private static readonly Regex Declaration = new(
@"CREATE\s+(?:OR\s+ALTER\s+)?(?<kind>PROCEDURE|PROC|FUNCTION|TYPE|TABLE|VIEW)\s+(?:\[?dbo\]?\.)?\[?(?<name>[^\]\s(]+)\]?",
RegexOptions.IgnoreCase | RegexOptions.Compiled);
private static readonly Regex ParameterDeclaration = new(
@"@(?<name>\w+)\s+(?:AS\s+)?[\w\[\]\.]+(?:\s*\([^)]*\))?(?:\s+READONLY)?\s*(?<def>=)?",
RegexOptions.IgnoreCase | RegexOptions.Compiled);
private static readonly Regex Reference = new(
@"(?<exec>EXEC(?:UTE)?\s+)?(?:@\w+\s*=\s*)?\[?dbo\]?\.\[?(?<name>[A-Za-z][A-Za-z0-9_\-]*)\]?(?<call>\s*\()?",
RegexOptions.IgnoreCase | RegexOptions.Compiled);
// ── Locating and parsing ──────────────────────────────────────────────────────────────────
private static string RepoRoot()
{
for (var dir = new DirectoryInfo(AppContext.BaseDirectory); dir != null; dir = dir.Parent)
if (Directory.Exists(Path.Combine(dir.FullName, "Fuchs_Database", "dbo"))
&& Directory.Exists(Path.Combine(dir.FullName, "Fuchs")))
return dir.FullName;
Assert.Skip("Fuchs_Database SSDT project not present in this checkout.");
return "";
}
internal static string StripSqlComments(string sql) =>
Regex.Replace(Regex.Replace(sql, @"/\*.*?\*/", " ", RegexOptions.Singleline), @"--[^\n]*", " ");
/// <summary>Parses one SSDT file into the object it declares, or null for non-object scripts.</summary>
internal static SqlObject? ParseObject(string sql)
{
sql = StripSqlComments(sql);
var m = Declaration.Match(sql);
if (!m.Success) return null;
string kind = m.Groups["kind"].Value.ToUpperInvariant() is "PROC" ? "PROCEDURE" : m.Groups["kind"].Value.ToUpperInvariant();
string header = "";
string rest = sql[(m.Index + m.Length)..];
if (kind == "FUNCTION")
{
int open = rest.IndexOf('('), depth = 0;
for (int i = open; open >= 0 && i < rest.Length; i++)
{
depth += rest[i] == '(' ? 1 : rest[i] == ')' ? -1 : 0;
if (depth == 0) { header = rest[(open + 1)..i]; break; }
}
}
else if (kind == "PROCEDURE")
{
header = Regex.Split(rest, @"^\s*AS\b", RegexOptions.Multiline | RegexOptions.IgnoreCase)[0];
header = Regex.Split(header, @"\bWITH\s+(?:EXECUTE|RECOMPILE|ENCRYPTION)", RegexOptions.IgnoreCase)[0];
}
var ps = ParameterDeclaration.Matches(header)
.Select(p => ("@" + p.Groups["name"].Value.ToLowerInvariant(), p.Groups["def"].Success))
.ToList();
return new SqlObject(m.Groups["name"].Value, kind, ps);
}
private static Dictionary<string, SqlObject> LoadProject()
{
var objects = new Dictionary<string, SqlObject>(StringComparer.OrdinalIgnoreCase);
foreach (var file in Directory.EnumerateFiles(Path.Combine(RepoRoot(), "Fuchs_Database", "dbo"), "*.sql", SearchOption.AllDirectories))
if (ParseObject(File.ReadAllText(file)) is { } o)
objects[o.Name] = o;
return objects;
}
/// <summary>
/// SQL object references in C# source. Comment lines are dropped (commented-out code and doc
/// comments name objects too) and adjacent concatenated literals are joined, so an
/// EXECUTE split over <c>"…" +</c> lines is seen as one statement.
/// </summary>
internal static IEnumerable<SqlReference> ScanSource(string file, string source)
{
var lines = source.Split('\n');
string code = string.Join('\n', lines.Select(l => l.TrimStart().StartsWith("//", StringComparison.Ordinal) ? "" : l));
code = Regex.Replace(code, @"/\*.*?\*/", m => new string('\n', m.Value.Count(c => c == '\n')), RegexOptions.Singleline);
// Join "a " + "b". The swallowed line breaks become JoinedBreak: it counts as a line for the
// reported line number but does not end the statement's argument list.
code = Regex.Replace(code, "\"(\\s*\\+\\s*)\\$?@?\"", m => new string(JoinedBreak, m.Groups[1].Value.Count(c => c == '\n')));
foreach (Match m in Reference.Matches(code))
{
string name = m.Groups["name"].Value;
if (name.StartsWith("sp_", StringComparison.OrdinalIgnoreCase) || name.StartsWith("sys", StringComparison.OrdinalIgnoreCase))
continue;
int line = code.Take(m.Index).Count(c => c is '\n' or JoinedBreak) + 1;
string tail = code[(m.Index + m.Length)..];
string args;
if (m.Groups["call"].Success)
{
int depth = 1, i = 0;
while (i < tail.Length && depth > 0) depth += tail[i++] switch { '(' => 1, ')' => -1, _ => 0 };
args = tail[..Math.Max(0, i - 1)];
}
else
{
int end = tail.IndexOfAny([';', '"', '\n']);
args = end < 0 ? tail : tail[..end];
}
args = args.Replace(JoinedBreak.ToString(), "", StringComparison.Ordinal).Trim();
yield return new SqlReference(file, line, name, m.Groups["exec"].Success, m.Groups["call"].Success, args);
}
}
private static List<SqlReference> ScanRepository()
{
string root = RepoRoot();
return ScannedProjects
.SelectMany(p => Directory.EnumerateFiles(Path.Combine(root, p), "*.cs", SearchOption.AllDirectories))
.Where(f => !Regex.IsMatch(f, @"[\\/](bin|obj)[\\/]"))
.SelectMany(f => ScanSource(Path.GetRelativePath(root, f), File.ReadAllText(f)))
.ToList();
}
// ── Binding rules ─────────────────────────────────────────────────────────────────────────
/// <summary>Binding problems of an EXECUTE argument list against a procedure's parameters.</summary>
internal static List<string> ExecuteProblems(string args, IReadOnlyList<(string Name, bool HasDefault)> ps)
{
var problems = new List<string>();
var supplied = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
bool sawNamed = false;
int position = 0;
foreach (var arg in args.Split(',', StringSplitOptions.TrimEntries | StringSplitOptions.RemoveEmptyEntries))
{
var named = Regex.Match(arg, @"^(@\w+)\s*=");
if (named.Success)
{
sawNamed = true;
string n = named.Groups[1].Value.ToLowerInvariant();
if (!ps.Any(p => p.Name == n)) problems.Add($"unknown parameter {n}");
supplied.Add(n);
continue;
}
if (sawNamed) problems.Add($"positional argument '{arg}' after a named one");
if (position >= ps.Count) { problems.Add($"argument '{arg}' beyond the {ps.Count} declared parameter(s)"); continue; }
supplied.Add(ps[position++].Name);
}
problems.AddRange(ps.Where(p => !p.HasDefault && !supplied.Contains(p.Name))
.Select(p => $"required parameter {p.Name} not supplied"));
return problems;
}
/// <summary>T-SQL functions take every declared argument (DEFAULT is passed explicitly).</summary>
internal static List<string> CallProblems(string args, IReadOnlyList<(string Name, bool HasDefault)> ps)
{
int count = string.IsNullOrWhiteSpace(args) ? 0 : Regex.Split(args, @",(?![^(]*\))").Length;
return count == ps.Count ? [] : [$"{count} argument(s), function declares {ps.Count}"];
}
internal static List<string> Problems(IEnumerable<SqlReference> refs, IReadOnlyDictionary<string, SqlObject> objects)
{
var problems = new List<string>();
foreach (var r in refs)
{
string at = $"{r.File}:{r.Line} [dbo].[{r.Name}]";
if (!objects.TryGetValue(r.Name, out var o)) { problems.Add($"{at}: not in Fuchs_Database"); continue; }
var found = r.IsExecute && o.Kind == "PROCEDURE" ? ExecuteProblems(r.Args, o.Parameters)
: r.IsCall && o.Kind == "FUNCTION" ? CallProblems(r.Args, o.Parameters)
: [];
problems.AddRange(found.Select(p => $"{at}: {p}"));
}
return problems;
}
// ── The contract, against the real repository ─────────────────────────────────────────────
[Fact]
public void ScanRepository_RealSources_FindsTheKnownCallSites()
{
// Guards the scanner itself: a regex regression that matched nothing would pass every check below.
var refs = ScanRepository();
Assert.True(refs.Count(r => r.IsExecute) >= 50, $"only {refs.Count(r => r.IsExecute)} EXECUTE call sites found");
Assert.Contains(refs, r => r.IsExecute && r.Name.Equals("fds__getDatevExports", StringComparison.OrdinalIgnoreCase));
Assert.Contains(refs, r => r.IsCall && r.Name.Equals("fis_getModuleAuth", StringComparison.OrdinalIgnoreCase));
}
[Fact]
public void CSharpSqlReferences_AgainstSsdtProject_ExistAndBind()
{
var problems = Problems(ScanRepository(), LoadProject());
Assert.True(problems.Count == 0, "C# ↔ SQL contract violations:\n" + string.Join('\n', problems));
}
[Fact]
public void MfrUpdtProcedures_AllDeclareTheSignatureFdsMfrClientCallsDynamically()
{
var updt = LoadProject().Values.Where(o => o.Name.StartsWith("mfr__updt__", StringComparison.OrdinalIgnoreCase)).ToList();
Assert.NotEmpty(updt);
Assert.All(updt, o => Assert.Equal(MfrUpdtSignature, o.Parameters.Select(p => p.Name)));
}
[Fact]
public void LoggerSqlSinkProcedure_ExistsInSsdtProject()
// Program.cs configures the OCORE MultiLogger sink by name (ADR 0024); a missing proc silently loses every DB log row.
=> Assert.True(LoadProject().TryGetValue("fds__admin_logdebug_ext", out var o) && o.Kind == "PROCEDURE");
// ── The rules themselves: succeeding and failing inputs ───────────────────────────────────
private static readonly (string, bool)[] Datev = [("@tgtdate", false), ("@mode", true), ("@files", false), ("@authuser", false)];
[Theory]
[InlineData("@tgtdate = @tgtdate, @files = 0, @authuser = @authuser")]
[InlineData("@tgtdate, @mode, @files, @authuser")]
[InlineData("@tgtdate, @mode, @files = 1, @authuser = @u")]
public void ExecuteProblems_CorrectlyBoundArguments_None(string args)
=> Assert.Empty(ExecuteProblems(args, Datev));
[Theory]
[InlineData("@tgtdate, @mode, @authuser", "required parameter @authuser")] // the former inv/datev call
[InlineData("@tgtdate = @t, @files = 0, @user = @u", "unknown parameter @user")]
[InlineData("@tgtdate = @t, @mode, @files = 0, @authuser = @u", "after a named one")]
[InlineData("@a, @b, @c, @d, @e", "beyond the 4 declared")]
[InlineData("", "required parameter @tgtdate")]
public void ExecuteProblems_MisboundArguments_Reported(string args, string expected)
=> Assert.Contains(ExecuteProblems(args, Datev), p => p.Contains(expected, StringComparison.Ordinal));
[Theory]
[InlineData("'fds_reports', @authuser", 2, true)]
[InlineData("LEFT(@a, 3), @b", 2, true)]
[InlineData("", 0, true)]
[InlineData("@authuser", 2, false)]
[InlineData("@a, @b, @c", 2, false)]
public void CallProblems_ArgumentCount_ReportedOnlyOnMismatch(string args, int declared, bool ok)
{
var ps = Enumerable.Range(0, declared).Select(i => ($"@p{i}", false)).ToList();
Assert.Equal(ok, CallProblems(args, ps).Count == 0);
}
[Fact]
public void ScanSource_ConcatenatedCommentedAndDocText_OnlyLiveStatementsWithFullArguments()
{
const string src = """
var a = "EXECUTE [dbo].[proc_a] @x, " +
"@y;";
// "EXECUTE [dbo].[proc_commented] @z;"
/// <c>[dbo].[proc_doc]</c>
var b = "SELECT [dbo].[fn_b](@p, LEFT(@q, 2)) AS v";
""";
var refs = ScanSource("x.cs", src).ToList();
Assert.Equal(["proc_a", "fn_b"], refs.Select(r => r.Name));
Assert.Equal("@x, @y", refs[0].Args);
Assert.Equal(1, refs[0].Line);
Assert.Equal("@p, LEFT(@q, 2)", refs[1].Args);
Assert.Equal(5, refs[1].Line);
}
[Fact]
public void Problems_ObjectMissingFromProject_Reported()
{
var objects = new Dictionary<string, SqlObject>(StringComparer.OrdinalIgnoreCase);
var refs = new[] { new SqlReference("x.cs", 3, "fds__gone", true, false, "@a") };
Assert.Equal(["x.cs:3 [dbo].[fds__gone]: not in Fuchs_Database"], Problems(refs, objects));
}
[Theory]
[InlineData("CREATE PROCEDURE [dbo].[p]\n\t@a as nvarchar(50)\n\t, @b int = 1 -- @c comment\nAS\nBEGIN SELECT 1 END", "PROCEDURE", "@a,@b", "False,True")]
[InlineData("create function [f](@count int = 10)\nRETURNS TABLE AS RETURN SELECT 1 as x", "FUNCTION", "@count", "True")]
[InlineData("CREATE FUNCTION [dbo].[g] (\n)\nRETURNS varchar(10) AS BEGIN RETURN '' END", "FUNCTION", "", "")]
[InlineData("CREATE TYPE [dbo].[tt] AS TABLE ([Id] int)", "TYPE", "", "")]
public void ParseObject_SsdtDeclarations_NameKindAndParameters(string sql, string kind, string names, string defaults)
{
var o = ParseObject(sql)!;
Assert.Equal(kind, o.Kind);
Assert.Equal(names, string.Join(",", o.Parameters.Select(p => p.Name)));
Assert.Equal(defaults, string.Join(",", o.Parameters.Select(p => p.HasDefault)));
}
}
+86
View File
@@ -0,0 +1,86 @@
using System;
using System.Globalization;
using System.Linq;
using Fuchs.intranet;
using Newtonsoft.Json.Linq;
using Xunit;
using static OCORE.SQL.sql;
namespace Fuchs.Tests;
/// <summary>
/// Regression tests for the OCORE string → number SQL parameter helpers. Saving a Stornorechnung
/// failed in production with "Error converting data type float to numeric": the negative totals
/// did not parse in the first (sign-less) attempt and fell back to the server's de-DE culture,
/// where "." is a group separator — "-19.95" became -1995 and JS float noise
/// ("-1234.5600000000002") overflowed numeric(10,3), so fds__setInvoice never ran.
/// Every test runs under de-DE, the production server culture.
/// </summary>
public class SqlNumberParameterTests : IDisposable
{
private readonly CultureInfo _culture = CultureInfo.CurrentCulture;
public SqlNumberParameterTests() => CultureInfo.CurrentCulture = new CultureInfo("de-DE");
public void Dispose() => CultureInfo.CurrentCulture = _culture;
[Theory]
[InlineData("-19.95", -19.95)]
[InlineData("-1234.56", -1234.56)]
[InlineData("-1234.5600000000002", -1234.56)]
[InlineData("-0.5", -0.5)]
[InlineData("1234.56", 1234.56)]
[InlineData("1234.5600000000002", 1234.56)]
[InlineData("-1,5", -1.5)]
[InlineData("-1.234,56", -1234.56)]
[InlineData("1.234,56", 1234.56)]
[InlineData("1,234.56", 1234.56)]
[InlineData(" -42 ", -42)]
[InlineData("-1.5E-05", -0.000015)]
[InlineData("0", 0)]
public void SQL_Float_StringValue_ParsesSignedAndCultureFormattedNumbers(string input, double expected)
{
var p = SQL_Float("@v", stringvalue: input);
var value = Assert.IsType<float>(p.Value);
Assert.Equal(expected, value, 0.01);
}
[Theory]
[InlineData("-19.95", -19.95)]
[InlineData("-1234.56", -1234.56)]
[InlineData("-1.234,56", -1234.56)]
[InlineData("1.234,56", 1234.56)]
[InlineData("-1,5", -1.5)]
public void SQL_Decimal_StringValue_ParsesSignedAndCultureFormattedNumbers(string input, double expected)
{
var p = SQL_Decimal("@v", input, precision: 10, scale: 3);
var value = Assert.IsType<decimal>(p.Value);
Assert.Equal((decimal)expected, value);
}
[Theory]
[InlineData("")]
[InlineData("NaN")]
[InlineData("abc")]
[InlineData("--5")]
public void SQL_Float_StringValue_InvalidOrEmpty_IsDbNull(string input)
{
var p = SQL_Float("@v", stringvalue: input);
Assert.Equal(DBNull.Value, p.Value);
}
[Theory]
[InlineData("")]
[InlineData("NaN")]
[InlineData("abc")]
public void SQL_Decimal_StringValue_InvalidOrEmpty_IsDbNull(string input)
{
var p = SQL_Decimal("@v", input);
Assert.Equal(DBNull.Value, p.Value);
}
}
@@ -0,0 +1,18 @@
using Xunit;
namespace Fuchs.Tests;
/// <summary>
/// Groups every test class that saves/mutates/restores the process-wide static
/// <see cref="OCORE.Logging.Logger"/> field (e.g. to exercise the <c>FIS_SQLOptions</c>/
/// <c>FdsSqlOptions</c> "no logger given" fallback). xUnit runs different test *classes* in
/// parallel by default (each is its own collection unless grouped); without this, two such tests
/// racing on the same static field could read/restore each other's value and fail intermittently
/// for reasons unrelated to the code under test. Putting them in one named collection makes xUnit
/// run them sequentially relative to each other while leaving the rest of the suite parallel.
/// </summary>
[CollectionDefinition(Name)]
public class StaticOcoreLoggerCollection
{
public const string Name = "OCORE.Logging.Logger (static)";
}
+213 -1
View File
@@ -3,6 +3,7 @@ using System.Collections.Generic;
using System.Diagnostics.Metrics; using System.Diagnostics.Metrics;
using System.Threading; using System.Threading;
using System.Threading.Tasks; using System.Threading.Tasks;
using Fuchs.intranet;
using Fuchs.Services; using Fuchs.Services;
using Microsoft.Extensions.Configuration; using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.DependencyInjection;
@@ -24,6 +25,7 @@ namespace Fuchs.Tests;
public class SystemStatusServiceTests public class SystemStatusServiceTests
{ {
private const string FuchsMeterName = "Fuchs.Intranet"; private const string FuchsMeterName = "Fuchs.Intranet";
private static readonly byte[] FakePdfBytes = { 0x25, 0x50, 0x44, 0x46, 0x2D }; // "%PDF-"
private static IConfiguration Config(Dictionary<string, string?>? overrides = null) private static IConfiguration Config(Dictionary<string, string?>? overrides = null)
{ {
@@ -51,6 +53,7 @@ public class SystemStatusServiceTests
Mock<IBlobStorageService>? blob = null, Mock<IBlobStorageService>? blob = null,
Mock<IMfrClientFactory>? mfr = null, Mock<IMfrClientFactory>? mfr = null,
Mock<IComService>? com = null, Mock<IComService>? com = null,
Mock<IPdfService>? pdf = null,
ProcessWebComSettings? mailer = null, ProcessWebComSettings? mailer = null,
FuchsEmailSettings? email = null, FuchsEmailSettings? email = null,
AzureBlobStorageSettings? blobSettings = null, AzureBlobStorageSettings? blobSettings = null,
@@ -60,13 +63,31 @@ public class SystemStatusServiceTests
var env = new Mock<IHostEnvironment>(); var env = new Mock<IHostEnvironment>();
env.SetupGet(e => e.EnvironmentName).Returns("Development"); env.SetupGet(e => e.EnvironmentName).Returns("Development");
var comMock = com ?? new Mock<IComService>();
if (com is null)
{
comMock.Setup(c => c.VerifyCredentialsAsync(It.IsAny<CancellationToken>()))
.ReturnsAsync(new MailerCredentialsCheckResult { Ok = true, Account = "acc" });
}
// Default PDF stub: renders any letter to a fixed, recognisable byte payload.
var pdfMock = pdf ?? new Mock<IPdfService>();
if (pdf is null)
{
pdfMock.Setup(p => p.WriteLetterAsync(It.IsAny<FuchsPdf.FdsTextBlocks>(), It.IsAny<bool>()))
.ReturnsAsync(new MigraDoc.DocumentObjectModel.Document());
pdfMock.Setup(p => p.DocToPdfBytes(It.IsAny<MigraDoc.DocumentObjectModel.Document>()))
.Returns(FakePdfBytes);
}
return new SystemStatusService( return new SystemStatusService(
config ?? Config(), config ?? Config(),
env.Object, env.Object,
provider ?? new ServiceCollection().BuildServiceProvider(), provider ?? new ServiceCollection().BuildServiceProvider(),
(blob ?? new Mock<IBlobStorageService>()).Object, (blob ?? new Mock<IBlobStorageService>()).Object,
(mfr ?? new Mock<IMfrClientFactory>()).Object, (mfr ?? new Mock<IMfrClientFactory>()).Object,
(com ?? new Mock<IComService>()).Object, comMock.Object,
pdfMock.Object,
Options.Create(mailer ?? new ProcessWebComSettings { Enabled = true, Token = "tok", BaseUrl = "https://api.example", AccountId = "acc", ServerId = "srv" }), Options.Create(mailer ?? new ProcessWebComSettings { Enabled = true, Token = "tok", BaseUrl = "https://api.example", AccountId = "acc", ServerId = "srv" }),
Options.Create(email ?? new FuchsEmailSettings()), Options.Create(email ?? new FuchsEmailSettings()),
Options.Create(blobSettings ?? new AzureBlobStorageSettings { Enabled = true }), Options.Create(blobSettings ?? new AzureBlobStorageSettings { Enabled = true }),
@@ -277,6 +298,63 @@ public class SystemStatusServiceTests
Assert.False(r.Ok); Assert.False(r.Ok);
} }
// ── Email probe ────────────────────────────────────────────────────────────
[Fact]
public async Task ProbeEmail_MailerDisabled_ReturnsDisabled()
{
var svc = Build(mailer: new ProcessWebComSettings { Enabled = false, AccountId = "acc", Token = "tok" });
var r = await svc.ProbeAsync("email");
Assert.Equal("email", r.Component);
Assert.Equal("disabled", r.Status);
Assert.False(r.Ok);
}
[Theory]
[InlineData("", "tok")]
[InlineData("acc", "")]
public async Task ProbeEmail_CredentialsMissing_ReturnsUnconfigured(string accountId, string token)
{
var svc = Build(mailer: new ProcessWebComSettings { Enabled = true, AccountId = accountId, Token = token });
var r = await svc.ProbeAsync("email");
Assert.Equal("unconfigured", r.Status);
Assert.False(r.Ok);
}
[Fact]
public async Task ProbeEmail_MailerConfirms_ReturnsOkWithAccount()
{
var com = new Mock<IComService>();
com.Setup(c => c.VerifyCredentialsAsync(It.IsAny<CancellationToken>()))
.ReturnsAsync(new MailerCredentialsCheckResult { Ok = true, Account = "acc-123" });
var svc = Build(com: com);
var r = await svc.ProbeAsync("email");
Assert.Equal("email", r.Component);
Assert.Equal("ok", r.Status);
Assert.True(r.Ok);
Assert.Equal("acc-123", r.Detail);
}
[Fact]
public async Task ProbeEmail_MailerRejectsCredentials_ReturnsError()
{
var com = new Mock<IComService>();
com.Setup(c => c.VerifyCredentialsAsync(It.IsAny<CancellationToken>()))
.ReturnsAsync(new MailerCredentialsCheckResult { Ok = false, Detail = "API-Fehler 401 (Unauthorized): {}" });
var svc = Build(com: com);
var r = await svc.ProbeAsync("email");
Assert.Equal("error", r.Status);
Assert.False(r.Ok);
Assert.Equal("API-Fehler 401 (Unauthorized): {}", r.Detail);
}
// ── Key Vault probe ──────────────────────────────────────────────────────── // ── Key Vault probe ────────────────────────────────────────────────────────
[Fact] [Fact]
public async Task ProbeKeyVault_ClientNotRegistered_ReturnsUnconfigured() public async Task ProbeKeyVault_ClientNotRegistered_ReturnsUnconfigured()
@@ -431,6 +509,140 @@ public class SystemStatusServiceTests
Assert.Null(r.OverrideRecipient); Assert.Null(r.OverrideRecipient);
} }
[Fact]
public async Task SendTestEmailAsync_AttachPdfDefault_AttachesRenderedPdfUnderFixedName()
{
Dictionary<string, byte[]>? capturedAttachments = null;
FuchsPdf.FdsTextBlocks? capturedBlocks = null;
var com = new Mock<IComService>();
com.Setup(c => c.SendEmailAsync(It.IsAny<string>(), It.IsAny<string>(), It.IsAny<string>(),
It.IsAny<string>(), It.IsAny<string>(), It.IsAny<Dictionary<string, byte[]>?>(), It.IsAny<string?>()))
.Callback<string, string, string, string, string, Dictionary<string, byte[]>?, string?>(
(_, _, _, _, _, att, _) => capturedAttachments = att)
.ReturnsAsync(true);
var pdf = new Mock<IPdfService>();
pdf.Setup(p => p.WriteLetterAsync(It.IsAny<FuchsPdf.FdsTextBlocks>(), false))
.Callback<FuchsPdf.FdsTextBlocks, bool>((tb, _) => capturedBlocks = tb)
.ReturnsAsync(new MigraDoc.DocumentObjectModel.Document());
pdf.Setup(p => p.DocToPdfBytes(It.IsAny<MigraDoc.DocumentObjectModel.Document>())).Returns(FakePdfBytes);
var svc = Build(com: com, pdf: pdf);
var r = await svc.SendTestEmailAsync("dest@example.test", "Betreff", "Zeile1\r\nZeile2");
Assert.True(r.Sent);
Assert.Equal(SystemStatusService.TestPdfAttachmentName, r.AttachmentName);
Assert.Contains(SystemStatusService.TestPdfAttachmentName, r.Message);
Assert.NotNull(capturedAttachments);
var (name, bytes) = Assert.Single(capturedAttachments!);
Assert.Equal(SystemStatusService.TestPdfAttachmentName, name);
Assert.Equal(FakePdfBytes, bytes);
// The letter carries the mail's own subject/body and addresses the requested recipient.
Assert.NotNull(capturedBlocks);
Assert.Equal("Betreff", capturedBlocks!.Subject);
Assert.Equal("Zeile1\nZeile2", capturedBlocks.Body);
Assert.Contains("dest@example.test", capturedBlocks.Address);
pdf.Verify(p => p.DocToPdfBytes(It.IsAny<MigraDoc.DocumentObjectModel.Document>()), Times.Once);
}
[Fact]
public async Task SendTestEmailAsync_AttachPdfFalse_SendsWithoutAttachmentAndSkipsRendering()
{
Dictionary<string, byte[]>? capturedAttachments = new();
var com = new Mock<IComService>();
com.Setup(c => c.SendEmailAsync(It.IsAny<string>(), It.IsAny<string>(), It.IsAny<string>(),
It.IsAny<string>(), It.IsAny<string>(), It.IsAny<Dictionary<string, byte[]>?>(), It.IsAny<string?>()))
.Callback<string, string, string, string, string, Dictionary<string, byte[]>?, string?>(
(_, _, _, _, _, att, _) => capturedAttachments = att)
.ReturnsAsync(true);
var pdf = new Mock<IPdfService>(MockBehavior.Strict); // any render call would throw
var svc = Build(com: com, pdf: pdf);
var r = await svc.SendTestEmailAsync("dest@example.test", "Betreff", "Body", attachPdf: false);
Assert.True(r.Sent);
Assert.Null(r.AttachmentName);
Assert.Null(capturedAttachments);
Assert.DoesNotContain("PDF", r.Message);
}
[Fact]
public async Task SendTestEmailAsync_PdfRenderingFails_DoesNotSendAndReportsError()
{
var com = new Mock<IComService>();
var pdf = new Mock<IPdfService>();
pdf.Setup(p => p.WriteLetterAsync(It.IsAny<FuchsPdf.FdsTextBlocks>(), It.IsAny<bool>()))
.ThrowsAsync(new InvalidOperationException("font resolver missing"));
var svc = Build(com: com, pdf: pdf);
var r = await svc.SendTestEmailAsync("dest@example.test", "Betreff", "Body");
Assert.False(r.Sent);
Assert.Null(r.AttachmentName);
Assert.Contains("PDF-Anhang", r.Message);
Assert.Contains("font resolver missing", r.Message);
Assert.Equal("dest@example.test", r.RequestedRecipient);
com.Verify(c => c.SendEmailAsync(It.IsAny<string>(), It.IsAny<string>(), It.IsAny<string>(),
It.IsAny<string>(), It.IsAny<string>(), It.IsAny<Dictionary<string, byte[]>?>(), It.IsAny<string?>()), Times.Never);
}
[Fact]
public async Task SendTestEmailAsync_MailerRejectsWithAttachment_ReportsAttachmentNotConfirmed()
{
var com = new Mock<IComService>();
com.Setup(c => c.SendEmailAsync(It.IsAny<string>(), It.IsAny<string>(), It.IsAny<string>(),
It.IsAny<string>(), It.IsAny<string>(), It.IsAny<Dictionary<string, byte[]>?>(), It.IsAny<string?>()))
.ReturnsAsync(false); // ADR 0028: also the outcome when attachmentsProcessed ≠ sent
var svc = Build(com: com);
var r = await svc.SendTestEmailAsync("dest@example.test", "Betreff", "Body");
Assert.False(r.Sent);
Assert.Equal(SystemStatusService.TestPdfAttachmentName, r.AttachmentName);
Assert.Contains("Anhang", r.Message);
}
[Fact]
public async Task SendTestEmailAsync_RealLetterRenderer_ProducesValidOnePagePdfAttachment()
{
// Real MigraDoc/PdfSharp rendering of the letter the service builds (raw, without the
// Spire PDF/A pass, so the test does not depend on a Spire license) — proves the text
// blocks the service assembles are renderable, not just that a stub was called.
Dictionary<string, byte[]>? capturedAttachments = null;
var com = new Mock<IComService>();
com.Setup(c => c.SendEmailAsync(It.IsAny<string>(), It.IsAny<string>(), It.IsAny<string>(),
It.IsAny<string>(), It.IsAny<string>(), It.IsAny<Dictionary<string, byte[]>?>(), It.IsAny<string?>()))
.Callback<string, string, string, string, string, Dictionary<string, byte[]>?, string?>(
(_, _, _, _, _, att, _) => capturedAttachments = att)
.ReturnsAsync(true);
var pdf = new Mock<IPdfService>();
pdf.Setup(p => p.WriteLetterAsync(It.IsAny<FuchsPdf.FdsTextBlocks>(), It.IsAny<bool>()))
.Returns<FuchsPdf.FdsTextBlocks, bool>((tb, draft) => FuchsPdf.WriteLetter(tb, draft, FuchsPdf.DeCulture));
pdf.Setup(p => p.DocToPdfBytes(It.IsAny<MigraDoc.DocumentObjectModel.Document>()))
.Returns<MigraDoc.DocumentObjectModel.Document>(FuchsPdf.DocToPdfBytesRaw);
var svc = Build(com: com, pdf: pdf);
var r = await svc.SendTestEmailAsync("dest@example.test", "Fuchs Intranet – Test-E-Mail", "Dies ist eine Test-E-Mail.\nZweite Zeile.");
Assert.True(r.Sent);
byte[] bytes = Assert.Single(capturedAttachments!).Value;
Assert.True(bytes.Length > 1000, "attachment should be a real rendered PDF");
Assert.Equal("%PDF", System.Text.Encoding.ASCII.GetString(bytes, 0, 4));
using var opened = PdfSharp.Pdf.IO.PdfReader.Open(new System.IO.MemoryStream(bytes), PdfSharp.Pdf.IO.PdfDocumentOpenMode.Import);
Assert.Equal(1, opened.PageCount);
}
[Fact]
public async Task SendTestEmailAsync_MissingRequiredFields_DoesNotRenderPdf()
{
var pdf = new Mock<IPdfService>(MockBehavior.Strict);
var svc = Build(pdf: pdf);
var r = await svc.SendTestEmailAsync("", "Betreff", "Body");
Assert.False(r.Sent);
Assert.Null(r.AttachmentName);
}
[Fact] [Fact]
public async Task SendTestEmailAsync_OverrideActive_ReportsOverrideRecipient() public async Task SendTestEmailAsync_OverrideActive_ReportsOverrideRecipient()
{ {
+24 -3
View File
@@ -69,13 +69,34 @@ public partial class IntranetController
{ {
if (!HasForm("to", "subject")) if (!HasForm("to", "subject"))
return BadRequest400(); return BadRequest400();
// attachpdf is the dialog's Ja/Nein select ("true"/"false"); absent → attach (default).
bool attachPdf = !bool.TryParse(Form("attachpdf"), out bool parsed) || parsed;
var result = await status.SendTestEmailAsync( var result = await status.SendTestEmailAsync(
Form("to"), Form("subject"), Form("body"), HttpContext.RequestAborted); Form("to"), Form("subject"), Form("body"), attachPdf, HttpContext.RequestAborted);
_logger.LogInformation("Admin test email requested by user={User} to={To} sent={Sent}", _logger.LogInformation("Admin test email requested by user={User} to={To} attachment={Attachment} sent={Sent}",
UserAccountID, result.RequestedRecipient, result.Sent); UserAccountID, result.RequestedRecipient, result.AttachmentName, result.Sent);
return AdminJson(new { result }); return AdminJson(new { result });
} }
// Maintenance action, reachable only from the admin-gated "Specialties" page
// (IntranetController.Specialties.cs / fis.specialties.js): backs up an invoice's
// stored PDF and clears it so the document regenerates on next read (ADR 0025 /
// Docs/Decisions/0027). GET-safe crawlers/prefetchers must never trigger this, so it
// requires POST despite the rest of the Admin module tolerating either verb.
case "backupclearinvoicefile":
{
if (!HttpContext.Request.Method.Equals("POST", StringComparison.OrdinalIgnoreCase))
return BadRequest400();
string invoiceId = string.IsNullOrWhiteSpace(code) ? Form("id") : code;
if (string.IsNullOrWhiteSpace(invoiceId))
return BadRequest400();
var backup = await _invoiceFileBackup.BackupAndClearAsync(invoiceId, UserAccountID, DbSec, HttpContext.RequestAborted);
_logger.LogWarning(
"Admin invoice-file backup/clear requested by user={User} invoiceId={InvoiceId} success={Success} reason={Reason}",
UserAccountID, invoiceId, backup.Success, backup.Reason);
return AdminJson(new { backup });
}
default: default:
_logger.LogWarning("Admin: no handler for action={Action}, user={User}", id, UserAccountID); _logger.LogWarning("Admin: no handler for action={Action}, user={User}", id, UserAccountID);
return BadRequest400(); return BadRequest400();
@@ -0,0 +1,132 @@
using Microsoft.AspNetCore.Mvc;
using OCORE.SQL;
using static OCORE.commons;
using static OCORE.OCORE_dictionaries;
using static OCORE.SQL.sql;
using static OCORE.web.mvc_helper_async;
namespace Fuchs.Controllers;
// Partial class: Administration user-group management module (fn=usrgrp).
//
// Split out as its own module (rather than folded into the usr module) per
// Docs/Decisions/0021-user-group-management.md, following the pattern
// Docs/Decisions/0015-split-admin-user-management-module.md already established for adding a new
// admin-only capability. Shares the Admin/usr modules' fds_sys > 4 gate (AdminMinAuthExclusive,
// GetSystemAdminAuthAsync, AdminJson — all in IntranetController.Admin.cs).
//
// The fis_admin_* stored procs/functions called below enforce the SAME SEPARATE, independent
// authorization axis as the usr module: the per-user "intranet_admin" setting via
// fis_admin_getUserAuth(@authaccount) > 1. An account with fds_sys > 4 but no intranet_admin
// setting will reach these endpoints yet get empty results / no-op writes from SQL — accounts
// doing group management need BOTH.
public partial class IntranetController
{
private async Task<IActionResult> Do_Process_AdminGroups(string fn, string id, string code)
{
_logger.LogDebug("Do_Process_AdminGroups action={Action} code={Code} user={User}", id, code, UserAccountID);
int authLevel = await GetSystemAdminAuthAsync(fn, id, code);
bool authorized = authLevel > AdminMinAuthExclusive;
if (id.Equals("auth", StringComparison.OrdinalIgnoreCase))
return await JSONAsync(new { manage = authorized ? 1 : 0, level = authLevel });
if (!authorized)
{
_logger.LogWarning("AdminGroups access denied for user={User} (fds_sys={Level}) action={Action}",
UserAccountID, authLevel, id);
return Unauthorized401();
}
switch (id.ToLowerInvariant())
{
case "list":
{
var res = await getSQLDatatable_async(
"SELECT * FROM [dbo].[fis_admin_getUserGroupList](@authaccount, NULL) ORDER BY [order];",
_intranet.Intranet__SQLConnectionString,
StdParamlist(SQL_VarChar("@authaccount", UserAccountID)),
Security: DbSec, options: SqlOpt(fn, id, code));
return AdminJson(new { groups = res.DataTable.toArrayofObjectDictionaries() });
}
case "create":
case "rename":
{
if (!HasForm("name")) return BadRequest400();
string name = Form("name");
string? groupId = id.Equals("rename", StringComparison.OrdinalIgnoreCase) ? Form("usergroup_id") : null;
if (id.Equals("rename", StringComparison.OrdinalIgnoreCase) && string.IsNullOrWhiteSpace(groupId))
return BadRequest400();
var pl = StdParamlist(
SQL_VarChar("@authaccount", UserAccountID),
SQL_VarChar("@usergroup_id", groupId, dbNull_IfEmpty: true),
SQL_NVarChar("@name", name));
System.Data.DataRow? savedRow;
try
{
var res = await getSQLDatatable_async(
"EXECUTE [dbo].[fis_admin_setUserGroup] @authaccount, @usergroup_id, @name, @enc_key;",
_intranet.Intranet__SQLConnectionString, pl,
Security: DbSec, options: SqlOpt(fn, id, code));
savedRow = res.Count > 0 ? res.FirstRow : null;
}
catch (Exception ex)
{
await NotifyAdminUserActionFailedAsync("Gruppe speichern fehlgeschlagen",
"Eine Benutzergruppe konnte nicht gespeichert werden.", groupId ?? "", ex);
return StatusCode(500, new { error = "save_failed" });
}
if (savedRow == null)
{
_logger.LogWarning("AdminGroups {Action} not successful group={Group} user={User}", id, groupId, UserAccountID);
return StatusCode(500, new { error = "save_failed" });
}
_logger.LogInformation("AdminGroups {Action} succeeded group={Group} user={User}",
id, savedRow.nz("usergroup_id"), UserAccountID);
return AdminJson(new { ok = true, usergroup_id = savedRow.nz("usergroup_id"), name = savedRow.nz("name") });
}
case "delete":
{
if (!HasForm("usergroup_id")) return BadRequest400();
string groupId = Form("usergroup_id");
bool success;
try
{
var res = await getSQLDatatable_async(
"EXECUTE [dbo].[fis_admin_delUserGroup] @authaccount, @usergroup_id;",
_intranet.Intranet__SQLConnectionString,
StdParamlist(SQL_VarChar("@authaccount", UserAccountID), SQL_VarChar("@usergroup_id", groupId)),
Security: DbSec, options: SqlOpt(fn, id, code));
success = res.Count > 0 && res.FirstRow[0] is true;
}
catch (Exception ex)
{
await NotifyAdminUserActionFailedAsync("Gruppe löschen fehlgeschlagen",
$"Die Benutzergruppe {groupId} konnte nicht gelöscht werden.", groupId, ex);
return StatusCode(500, new { error = "delete_failed" });
}
if (!success)
{
_logger.LogWarning("AdminGroups delete not successful group={Group} user={User}", groupId, UserAccountID);
return StatusCode(500, new { error = "delete_failed" });
}
_logger.LogInformation("AdminGroups delete succeeded group={Group} user={User}", groupId, UserAccountID);
return AdminJson(new { ok = true });
}
default:
_logger.LogWarning("AdminGroups: no handler for action={Action}, user={User}", id, UserAccountID);
return BadRequest400();
}
}
}
@@ -1,4 +1,4 @@
using System.Web; using System.Web;
using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc;
using Microsoft.Data.SqlClient; using Microsoft.Data.SqlClient;
using OCORE.SQL; using OCORE.SQL;
@@ -44,10 +44,16 @@ public partial class IntranetController
{ {
case "list": case "list":
{ {
// Optional useraccount_id narrows the result to a single account so the frontend can
// refresh one row in place (e.g. after an email or group-membership change) instead
// of re-rendering the whole grid and losing the current selection.
string? filterId = HasForm("useraccount_id") ? Form("useraccount_id") : null;
var res = await getSQLDatatable_async( var res = await getSQLDatatable_async(
"SELECT * FROM [dbo].[fis_admin_getUserAccountList](@authaccount, NULL, NULL, NULL) ORDER BY [order];", "SELECT * FROM [dbo].[fis_admin_getUserAccountList](@authaccount, @useraccount_id, NULL, NULL) ORDER BY [order];",
_intranet.Intranet__SQLConnectionString, _intranet.Intranet__SQLConnectionString,
StdParamlist(SQL_VarChar("@authaccount", UserAccountID)), StdParamlist(
SQL_VarChar("@authaccount", UserAccountID),
SQL_VarChar("@useraccount_id", filterId, dbNull_IfEmpty: true)),
Security: DbSec, options: SqlOpt(fn, id, code)); Security: DbSec, options: SqlOpt(fn, id, code));
return AdminJson(new { users = res.DataTable.toArrayofObjectDictionaries() }); return AdminJson(new { users = res.DataTable.toArrayofObjectDictionaries() });
} }
@@ -175,6 +181,113 @@ public partial class IntranetController
return AdminJson(new { ok = true }); return AdminJson(new { ok = true });
} }
case "create":
{
if (!HasForm("name", "firstname", "email")) return BadRequest400();
string name = Form("name");
string firstname = Form("firstname");
string email = Form("email");
var pl = StdParamlist(
SQL_VarChar("@authaccount", UserAccountID),
SQL_NVarChar("@name", name),
SQL_NVarChar("@firstname", firstname),
SQL_VarChar("@title", Form("title"), dbNull_IfEmpty: true),
SQL_VarChar("@gender", Form("gender"), dbNull_IfEmpty: true),
SQL_VarChar("@email", email),
SQL_VarChar("@mobile", Form("mobile"), dbNull_IfEmpty: true),
SQL_NVarChar("@password", null, dbNull_IfEmpty: true));
System.Data.DataRow? createdRow;
try
{
var res = await getSQLDatatable_async(
"EXECUTE [dbo].[fis_admin_createUserAccount] @authaccount, @name, @firstname, " +
"@title, @gender, @email, @mobile, @password, @enc_key;",
_intranet.Intranet__SQLConnectionString, pl,
Security: DbSec, options: SqlOpt(fn, id, code));
createdRow = res.Count > 0 ? res.FirstRow : null;
}
catch (SqlException ex) when (ex.Number == 60001)
{
_logger.LogWarning("AdminUsers create: duplicate email rejected user={User}", UserAccountID);
return StatusCode(409, new { error = "duplicate_email" });
}
catch (Exception ex)
{
await NotifyAdminUserActionFailedAsync("Benutzer anlegen fehlgeschlagen",
"Ein neues Benutzerkonto konnte nicht angelegt werden.", "", ex);
return StatusCode(500, new { error = "create_failed" });
}
if (createdRow == null)
{
_logger.LogWarning("AdminUsers create not successful user={User}", UserAccountID);
return StatusCode(500, new { error = "create_failed" });
}
string newId = createdRow.nz("useraccount_id");
string newPassword = createdRow.nz("newpassword");
string targetName = $"{firstname} {name}".Trim();
var (subject, html) = BuildWelcomeEmail(targetName, email, newPassword);
bool emailed = email.Length > 5 &&
await _comService.SendEmailAsync("usrwelcome_" + email, subject, html, email, targetName, null);
if (!emailed)
{
_logger.LogWarning("AdminUsers create: welcome mail not delivered target={Target} user={User}",
newId, UserAccountID);
await _events.UserIssueAsync("Neuer Benutzer: E-Mail-Versand fehlgeschlagen",
$"Die Zugangsdaten für das neue Benutzerkonto {newId} konnten nicht per E-Mail zugestellt werden.",
UserAccountID, new Dictionary<string, object?> { ["target"] = newId, ["email"] = email });
}
_logger.LogInformation("AdminUsers create succeeded target={Target} emailed={Emailed} user={User}",
newId, emailed, UserAccountID);
return AdminJson(new { ok = true, useraccount_id = newId, emailed });
}
case "delete":
{
if (!HasForm("useraccount_id")) return BadRequest400();
string targetId = Form("useraccount_id");
if (targetId.Equals(UserAccountID, StringComparison.OrdinalIgnoreCase))
{
_logger.LogWarning("AdminUsers delete: self-delete rejected user={User}", UserAccountID);
return StatusCode(409, new { error = "cannot_delete_self" });
}
bool success;
try
{
var pl = StdParamlist(
SQL_VarChar("@authaccount", UserAccountID),
SQL_VarChar("@useraccount_id", targetId));
var res = await getSQLDatatable_async(
"EXECUTE [dbo].[fis_admin_delUserAccount] @authaccount, @useraccount_id;",
_intranet.Intranet__SQLConnectionString, pl,
Security: DbSec, options: SqlOpt(fn, id, code));
success = res.Count > 0 && res.FirstRow[0] is true;
}
catch (Exception ex)
{
await NotifyAdminUserActionFailedAsync("Benutzer löschen fehlgeschlagen",
$"Das Benutzerkonto {targetId} konnte nicht gelöscht werden.", targetId, ex);
return StatusCode(500, new { error = "delete_failed" });
}
if (!success)
{
_logger.LogWarning("AdminUsers delete not successful target={Target} user={User}", targetId, UserAccountID);
return StatusCode(500, new { error = "delete_failed" });
}
_logger.LogInformation("AdminUsers delete succeeded target={Target} user={User}", targetId, UserAccountID);
return AdminJson(new { ok = true });
}
case "groups": case "groups":
{ {
if (!HasForm("useraccount_id")) return BadRequest400(); if (!HasForm("useraccount_id")) return BadRequest400();
@@ -238,6 +351,16 @@ public partial class IntranetController
$"<p>Guten Tag {targetName},<br/>Ihr Passwort wurde von einem Administrator zurückgesetzt.<br/>" + $"<p>Guten Tag {targetName},<br/>Ihr Passwort wurde von einem Administrator zurückgesetzt.<br/>" +
$"Ihr neues Passwort: {HttpUtility.HtmlEncode(newPassword)}</p>"); $"Ihr neues Passwort: {HttpUtility.HtmlEncode(newPassword)}</p>");
/// <summary>
/// Builds the subject/HTML body for the "a new intranet account was created for you"
/// notification. Extracted as pure logic (no DB/HTTP), mirroring <see cref="BuildPasswordResetEmail"/>.
/// </summary>
internal static (string Subject, string Html) BuildWelcomeEmail(string targetName, string email, string newPassword) => (
"sanitaerfuchs.de Intranet Zugang eingerichtet",
$"<p>Guten Tag {targetName},<br/>für Sie wurde ein Zugang zum sanitaerfuchs.de Intranet eingerichtet.<br/>" +
$"Ihre E-Mail-Adresse: {HttpUtility.HtmlEncode(email)}<br/>" +
$"Ihr Passwort: {HttpUtility.HtmlEncode(newPassword)}</p>");
/// <summary> /// <summary>
/// Logs and raises a user-facing notification for an admin user-management action that /// Logs and raises a user-facing notification for an admin user-management action that
/// threw unexpectedly, per the "interrupting exceptions notify the user" rule /// threw unexpectedly, per the "interrupting exceptions notify the user" rule
+10 -10
View File
@@ -55,22 +55,22 @@ public partial class IntranetController
{ {
importFailed = true; importFailed = true;
importFailure = exc.Message; importFailure = exc.Message;
_logger.LogError(exc, // EventId.Name keeps the exact CodeReference the DB log used before the
"Banking upload SQL exception — file={File} destTable={DestTable} user={User}", // generic SQL sink existed (ADR 0024); {Data} carries uid/tmptbl into it.
fle.FileName, dtwa.DestinationTableName, UserAccountID); _logger.LogError(new EventId(0, "IntranetController.bam.up - sql exception"), exc,
_intranet.debug_log("IntranetController.bam.up - sql exception", "Banking upload SQL exception — file={File} destTable={DestTable} user={User} data={Data}",
exc, UserAccountID, new { uid = dtwa.InstanceGUID, tmptbl }); fle.FileName, dtwa.DestinationTableName, UserAccountID,
new { uid = dtwa.InstanceGUID, tmptbl });
}; };
dtwa.OnCommandAfterError += (_, exc) => dtwa.OnCommandAfterError += (_, exc) =>
{ {
importFailed = true; importFailed = true;
importFailure = exc.Message; importFailure = exc.Message;
_logger.LogError(exc, _logger.LogError(new EventId(0, "IntranetController.bam.up - command-after exception"), exc,
"Banking upload merge-command exception — file={File} destTable={DestTable} " + "Banking upload merge-command exception — file={File} destTable={DestTable} " +
"rescueTable={RescueTable} user={User}", "rescueTable={RescueTable} user={User} data={Data}",
fle.FileName, dtwa.DestinationTableName, tmptbl, UserAccountID); fle.FileName, dtwa.DestinationTableName, tmptbl, UserAccountID,
_intranet.debug_log("IntranetController.bam.up - command-after exception", new { uid = dtwa.InstanceGUID, tmptbl });
exc, UserAccountID, new { uid = dtwa.InstanceGUID, tmptbl });
}; };
_logger.LogDebug("Banking upload parsed {Rows} rows → temp table submit (user={User})", _logger.LogDebug("Banking upload parsed {Rows} rows → temp table submit (user={User})",
tbl.Rows.Count, UserAccountID); tbl.Rows.Count, UserAccountID);
@@ -3,6 +3,7 @@ using Fuchs.Services;
using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc;
using Newtonsoft.Json; using Newtonsoft.Json;
using Newtonsoft.Json.Linq; using Newtonsoft.Json.Linq;
using static OCORE.SQL.sql;
using static OCORE.web.mvc_helper_async; using static OCORE.web.mvc_helper_async;
namespace Fuchs.Controllers; namespace Fuchs.Controllers;
@@ -14,6 +15,20 @@ namespace Fuchs.Controllers;
// only signals (ADR 0007). // only signals (ADR 0007).
public partial class IntranetController public partial class IntranetController
{ {
/// <summary>
/// The date the calling user may backdate an invoice to (last working day of the previous month), or
/// null when not allowed — fds_inv &gt; 2 and no invoice finalized after that date yet. The SQL function
/// is the single source; fds__setInvoiceFinal re-checks it when @backdate = 1 (ADR 0032).
/// </summary>
private async Task<DateOnly?> GetInvoiceBackdateAsync(string fn, string id, string code)
{
var val = await getSQLValue_async<string>(
"SELECT CONVERT(varchar(10), [dbo].[fds__fn_invoiceBackdate](@authuser), 23);",
_intranet.Intranet__SQLConnectionString, "",
StdParamlist(), Security: DbSec, options: SqlOpt(fn, id, code));
return InvoiceDates.ParseBackdate(val.Result);
}
/// <summary>Standard 410 when a session token is unknown/expired — the client re-opens the draft.</summary> /// <summary>Standard 410 when a session token is unknown/expired — the client re-opens the draft.</summary>
private IActionResult DraftGone() => StatusCode(410, new { error = "expired" }); private IActionResult DraftGone() => StatusCode(410, new { error = "expired" });
@@ -71,14 +86,20 @@ public partial class IntranetController
return await JSONAsync(new { ok = true, version = session.Version }); return await JSONAsync(new { ok = true, version = session.Version });
} }
// POST inv/dpreview — { token } → { img[], total } (rendered straight from the cache) // POST inv/dpreview — { token } → { img[], total, backdate } (rendered straight from the cache).
// backdate ("yyyy-MM-dd" or null) drives the admin-only "Rechnungsdatum Vormonat" checkbox (ADR 0032).
private async Task<IActionResult> HandleDraftPreview(string fn, string id, string code) private async Task<IActionResult> HandleDraftPreview(string fn, string id, string code)
{ {
if (!HasForm("token")) return BadRequest400(); if (!HasForm("token")) return BadRequest400();
var doc = _invoiceDrafts.RenderPreview(Form("token")); string token = Form("token");
return await PdfPreviewAsync("inv/dpreview", token, async () =>
{
var doc = await _invoiceDrafts.RenderPreviewAsync(token);
if (doc == null) return DraftGone(); if (doc == null) return DraftGone();
var imgcol = await _pdf.DocToImageCollectionAsync(doc); var imgcol = await _pdf.DocToImageCollectionAsync(doc);
return await JSONAsync(new { img = imgcol.ImgB64Array, total = imgcol.TotalPages }); DateOnly? backdate = await GetInvoiceBackdateAsync(fn, id, code);
return await JSONAsync(new { img = imgcol.ImgB64Array, total = imgcol.TotalPages, backdate = backdate?.ToString("yyyy-MM-dd") });
});
} }
// POST inv/dsave — { token } → { ok, invid }; flush cache→DB + business event + draftReady // POST inv/dsave — { token } → { ok, invid }; flush cache→DB + business event + draftReady
@@ -86,16 +107,15 @@ public partial class IntranetController
{ {
if (!HasForm("token")) return BadRequest400(); if (!HasForm("token")) return BadRequest400();
string token = Form("token"); string token = Form("token");
var before = _invoiceDrafts.Get(token); // Create vs update is decided inside the flush (flushes of one session are serialised), not
if (before == null) return DraftGone(); // from the session state seen here: a save overlapping the first registration is an update.
bool existed = !string.IsNullOrEmpty(before.InvId); var flush = await _invoiceDrafts.FlushToDbAsync(token, UserAccountID, DbSec);
if (flush == null) return DraftGone();
var fdInv = await _invoiceDrafts.FlushToDbAsync(token, UserAccountID, DbSec); var fdInv = flush.Data;
if (fdInv == null) return DraftGone();
if (string.IsNullOrEmpty(fdInv.Id)) if (string.IsNullOrEmpty(fdInv.Id))
return await InvoiceIssueResult("Der Zwischenstand konnte aufgrund eines Fehlers nicht gespeichert werden."); return await InvoiceIssueResult("Der Zwischenstand konnte aufgrund eines Fehlers nicht gespeichert werden.");
await _events.InvoiceDraftRegisteredAsync(fdInv, existed, UserAccountID); await _events.InvoiceDraftRegisteredAsync(fdInv, flush.Updated, UserAccountID);
var after = _invoiceDrafts.Get(token); var after = _invoiceDrafts.Get(token);
if (after != null) await _draftNotifier.SignalDraftReadyAsync(after.Token, after.Version); if (after != null) await _draftNotifier.SignalDraftReadyAsync(after.Token, after.Version);
return await JSONAsync(new { ok = true, invid = fdInv.Id }); return await JSONAsync(new { ok = true, invid = fdInv.Id });
@@ -131,6 +131,7 @@ public partial class IntranetController
return await HandleDatev(fn, id, code); return await HandleDatev(fn, id, code);
case "rdoc": case "rdoc":
if (!HasForm("id")) { _logger.LogWarning("rdoc: missing form field 'id', user={User}", UserAccountID); return BadRequest400(); }
_logger.LogDebug("rdoc: report document get id={DocId}, user={User}", Form("id"), UserAccountID); _logger.LogDebug("rdoc: report document get id={DocId}, user={User}", Form("id"), UserAccountID);
return await HandleReportDoc(fn, id, code, Form("id")); return await HandleReportDoc(fn, id, code, Form("id"));
@@ -16,10 +16,22 @@ namespace Fuchs.Controllers;
// Partial class: Invoice sub-handlers // Partial class: Invoice sub-handlers
public partial class IntranetController public partial class IntranetController
{ {
/// <summary>True when <paramref name="raw"/> is an mfr invoice id (positive integer); Intranet-created
/// invoices use alphanumeric ids and have no mfr entity to reset.</summary>
internal static bool TryGetMfrInvoiceId(string? raw, out long mfrId)
=> long.TryParse(raw?.Trim(), NumberStyles.None, CultureInfo.InvariantCulture, out mfrId) && mfrId > 0;
private async Task<IActionResult> HandleInvoicePget(string fn, string id, string code) private async Task<IActionResult> HandleInvoicePget(string fn, string id, string code)
{ {
if (!HasForm("id")) { _logger.LogWarning("HandleInvoicePget: missing 'id' form field user={User}", UserAccountID); return BadRequest400(); } if (!HasForm("id")) { _logger.LogWarning("HandleInvoicePget: missing 'id' form field user={User}", UserAccountID); return BadRequest400(); }
if (!long.TryParse(Form("id"), out long tgtid)) { _logger.LogWarning("HandleInvoicePget: invalid 'id' value='{Value}' user={User}", Form("id"), UserAccountID); return BadRequest400(); } if (!TryGetMfrInvoiceId(Form("id"), out long tgtid))
{
// Intranet-created invoices carry an alphanumeric Id (fds__fn_invoice_id) and have no mfr
// entity to reset. Both storno paths call pget first, so rejecting them here (400) left the
// storno window empty. Legacy parity: skip the mfr reset and report success.
_logger.LogDebug("HandleInvoicePget: non-mfr invoice id='{Value}', nothing to reset user={User}", Form("id"), UserAccountID);
return await JSONAsync(new { ok = true });
}
_logger.LogDebug("HandleInvoicePget tgtid={TgtId} user={User}", tgtid, UserAccountID); _logger.LogDebug("HandleInvoicePget tgtid={TgtId} user={User}", tgtid, UserAccountID);
using (var mfr = _mfrFactory.Create()) using (var mfr = _mfrFactory.Create())
@@ -263,6 +275,16 @@ public partial class IntranetController
return await JSONAsync(new { payments = sqldt.Tables("items").toArrayofObjectDictionaries() }); return await JSONAsync(new { payments = sqldt.Tables("items").toArrayofObjectDictionaries() });
} }
/// <summary>
/// DATEV overview (<c>inv/datev</c>). Parameters are bound by name: the proc's signature is
/// (@tgtdate, @mode, @files, @authuser) and the former positional call put @authuser into
/// @files and never supplied @authuser, so every call failed. @files = 0 — the overview needs
/// no PDF bytes. Result sets in proc order: header, invoice documents, bookings, debtors.
/// </summary>
internal const string DatevOverviewSql =
"EXECUTE [dbo].[fds__getDatevExports] @tgtdate = @tgtdate, @mode = @mode, @files = 0, @authuser = @authuser;";
internal static readonly string[] DatevOverviewTables = { "admin", "files", "invoices", "debits" };
private async Task<IActionResult> HandleDatev(string fn, string id, string code) private async Task<IActionResult> HandleDatev(string fn, string id, string code)
{ {
if (!DateTime.TryParseExact(Form("tgt"), "yy-MM-dd", if (!DateTime.TryParseExact(Form("tgt"), "yy-MM-dd",
@@ -274,10 +296,10 @@ public partial class IntranetController
string mode = Form("mode").ne("m"); string mode = Form("mode").ne("m");
_logger.LogDebug("HandleDatev tgtdate={TgtDate} mode={Mode} user={User}", tgtdate.ToString("yy-MM-dd"), mode, UserAccountID); _logger.LogDebug("HandleDatev tgtdate={TgtDate} mode={Mode} user={User}", tgtdate.ToString("yy-MM-dd"), mode, UserAccountID);
var dset = await getSQLDataSet_async( var dset = await getSQLDataSet_async(
"EXECUTE [dbo].[fds__getDatevExports] @tgtdate, @mode, @authuser;", DatevOverviewSql,
_intranet.Intranet__SQLConnectionString, _intranet.Intranet__SQLConnectionString,
StdParamlist(SQL_Date("@tgtdate", tgtdate), SQL_VarChar("@mode", mode)), StdParamlist(SQL_Date("@tgtdate", tgtdate), SQL_VarChar("@mode", mode)),
tablenames: new[] { "files", "invoices", "debits" }, tablenames: DatevOverviewTables,
Security: DbSec, options: SqlOpt(fn, id, code)); Security: DbSec, options: SqlOpt(fn, id, code));
_logger.LogDebug("HandleDatev tgtdate={TgtDate} files={FileCount} invoices={InvCount} user={User}", _logger.LogDebug("HandleDatev tgtdate={TgtDate} files={FileCount} invoices={InvCount} user={User}",
tgtdate.ToString("yy-MM-dd"), dset.Tables("files").Rows.Count, dset.Tables("invoices").Rows.Count, UserAccountID); tgtdate.ToString("yy-MM-dd"), dset.Tables("files").Rows.Count, dset.Tables("invoices").Rows.Count, UserAccountID);
@@ -293,17 +315,48 @@ public partial class IntranetController
_logger.LogDebug("HandleReportDoc reportid={ReportId} typ={Typ} user={User}", reportid, Form("typ"), UserAccountID); _logger.LogDebug("HandleReportDoc reportid={ReportId} typ={Typ} user={User}", reportid, Form("typ"), UserAccountID);
byte[]? content = null; byte[]? content = null;
var file = _mfr.GetReportDoc(ref content, reportid); var file = _mfr.GetReportDoc(ref content, reportid);
if (file == null) string fileName, mimeType;
if (file != null && content is { Length: > 0 })
{
fileName = file.Name;
mimeType = file.MimeType();
}
else
{
// Regenerate-on-miss (ADR 0025). Finalised, non-external only: fds__setInvoiceFile
// writes WHERE [file] IS NULL, so persisting a draft render here would permanently
// block the real final PDF at finalisation.
var fdInv = await _invoices.LoadInvoiceAsync(reportid, UserAccountID, DbSec);
bool isExternal = fdInv.InvoiceRegistration?.getItem("IsExternal", false) is true;
if (!string.IsNullOrEmpty(fdInv.Id) && !fdInv.IsDraft && !isExternal)
{
_logger.LogWarning("HandleReportDoc: no stored document for reportid={ReportId}, regenerating, user={User}", reportid, UserAccountID);
content = await _invoices.StoreInvoiceDocumentFileAsync(fdInv, fdInv.IsDraft, UserAccountID, DbSec);
}
if (content == null || content.Length == 0)
{ {
_logger.LogWarning("HandleReportDoc: document not found reportid={ReportId} user={User}", reportid, UserAccountID); _logger.LogWarning("HandleReportDoc: document not found reportid={ReportId} user={User}", reportid, UserAccountID);
return StatusCode(404, new { error = "Dokument wurde nicht gefunden" }); return StatusCode(404, new { error = "Dokument wurde nicht gefunden" });
} }
_logger.LogDebug("HandleReportDoc found reportid={ReportId} fileName={FileName} mimeType={MimeType} size={Size} user={User}", fileName = SafeDocumentFileName(
reportid, file.Name, file.MimeType(), content?.Length ?? 0, UserAccountID); fdInv.InvoiceRegistration?.getString("DocumentName") ?? "", $"Rechnung_{fdInv.Id.ne(reportid)}.pdf");
return Form("typ") != "img" mimeType = "application/pdf";
? await FileContentResultAsync(content!, file.MimeType(), file.Name)
: await JSONAsync(new { id = reportid, img = await BuildPdfImageArray(content!) });
} }
_logger.LogDebug("HandleReportDoc found reportid={ReportId} fileName={FileName} mimeType={MimeType} size={Size} user={User}",
reportid, fileName, mimeType, content.Length, UserAccountID);
if (Form("typ") != "img")
return await FileContentResultAsync(content, mimeType, fileName);
return await PdfPreviewAsync("inv/rdoc", reportid, async () =>
await JSONAsync(new { id = reportid, img = await BuildPdfImageArray(content!) }));
}
/// <summary>
/// Download filename for a regenerated document — the one shared sanitiser
/// (<see cref="fds.FdsDocumentNames.SafeFileName"/>) that <c>FdsMfr.GetReportDoc</c> also
/// applies to stored documents, so both paths name the same DocumentName identically.
/// </summary>
internal static string SafeDocumentFileName(string documentName, string fallback) =>
fds.FdsDocumentNames.SafeFileName(documentName, fallback);
private async Task<IActionResult> HandleReportDocByName(string fn, string id, string code) private async Task<IActionResult> HandleReportDocByName(string fn, string id, string code)
{ {
@@ -422,7 +475,9 @@ public partial class IntranetController
{ {
["Id"] = d["Id"], ["Id"] = d["Id"],
["net_val"] = net, ["net_val"] = net,
["vat_val"] = net * vat * 0.01, // Cent-rounded in decimal like InvoiceDraftCalculator; the former double product
// (e.g. -615.3757999999999) carried float noise into the draft's VAT sums.
["vat_val"] = Math.Round((decimal)net * (decimal)vat / 100m, 2, MidpointRounding.AwayFromZero),
["vat"] = vat == 0 ? "" : vat.ToString("0.00", FuchsPdf.DeCulture) + "%", ["vat"] = vat == 0 ? "" : vat.ToString("0.00", FuchsPdf.DeCulture) + "%",
["svcnet_val"] = d.no("value_service", 0), ["svcnet_val"] = d.no("value_service", 0),
["net"] = d.no("value", 0), ["net"] = d.no("value", 0),
@@ -434,7 +489,10 @@ public partial class IntranetController
["NameOrNumber"] = "", ["NameOrNumber"] = "",
["htmltext"] = d["Text"], ["htmltext"] = d["Text"],
["position"] = d["Position"], ["position"] = d["Position"],
["SortOrder"] = d["SortOrder"] ["SortOrder"] = d["SortOrder"],
// Round-trips the referenced invoice (CanceledInvoice / IntInvoiceAllocation) so a
// re-saved draft keeps it — fds__setInvoiceFinal needs it for Replaces/allocations.
["reference_InvId"] = d.nz("reference_InvId") is { Length: > 0 } refId ? refId : null
}); });
} }
sdic["items"] = itm; sdic["items"] = itm;
@@ -0,0 +1,125 @@
using System.Diagnostics;
using Fuchs.Observability;
using Microsoft.AspNetCore.Mvc;
namespace Fuchs.Controllers;
// Partial class: shared guard for every "PDF-Vorschau" endpoint (inv/dpreview, rem/dpreview,
// req/idoc+img, rem/idoc+img, inv/rdoc+img, rem/rdoc+img).
//
// A preview render can fail deep inside MigraDoc / Spire (fonts, licence, malformed HTML in an
// item text, an oversized inline image, ...). Before this guard such a failure only surfaced as
// the generic catch-all in Do(): an ErrorLog.txt line on the web server, nothing in the DB log
// support actually reads, and an empty "Der Server hat einen Fehler gemeldet:" alert in the
// browser. Every preview now goes through PdfPreviewAsync so the failure is (1) logged with
// what/ref/user/duration — which the SQL log sink (ADR 0024) additionally persists to
// fds__debuglog — (2) counted, (3) toasted to the user and (4) returned with a readable
// description header.
public partial class IntranetController
{
internal const string PdfPreviewLogReference = "PDF preview failed";
/// <summary>
/// Runs <paramref name="render"/> (the endpoint body that produces the preview response) and
/// turns any exception into a fully logged, user-readable 500.
/// </summary>
/// <param name="what">Endpoint tag, e.g. <c>inv/dpreview</c>.</param>
/// <param name="refId">Draft token / invoice / reminder / report id the preview is for.</param>
private async Task<IActionResult> PdfPreviewAsync(string what, string refId, Func<Task<IActionResult>> render)
{
var sw = Stopwatch.StartNew();
using var act = FuchsTelemetry.StartActivity("pdf.preview");
act?.SetTag("fuchs.pdf.preview.what", what);
act?.SetTag("fuchs.pdf.preview.ref", refId);
_logger.LogDebug("PDF preview {What} ref={RefId} user={User} started", what, refId, UserAccountID);
try
{
var result = await render();
sw.Stop();
// The client (postXT) gives up after a fixed timeout and aborts the connection while a
// slow render/regenerate-on-miss (ADR 0025) is still running server-side; that work is
// deliberately NOT cancelled — the regenerated document must still be stored so the
// next click is fast (ADR 0025) — but writing the now-finished result back to a socket
// the client already closed would only surface as a low-level framework/Kestrel
// exception that never reaches an EventId'd `_logger` call: it is either below the SQL
// sink's Warning floor or purely a debugger first-chance notification that never goes
// through ILogger at all — exactly the "exception in the debug console but nothing in
// fds__debuglog" gap. Recognise the disconnect here instead and log it deliberately, at
// Warning, so it is guaranteed to land in fds__debuglog (ADR 0024) — and skip the write.
if (HttpContext.RequestAborted.IsCancellationRequested)
{
FuchsTelemetry.PdfPreviews.Add(1,
new KeyValuePair<string, object?>("what", what),
new KeyValuePair<string, object?>("outcome", "client-disconnected"));
_logger.LogWarning(
"PDF preview {What} ref={RefId} user={User} finished in {Ms} ms but the client had already disconnected (timeout) — result discarded",
what, refId, UserAccountID, sw.ElapsedMilliseconds);
return new EmptyResult();
}
// A draft session that expired between page load and preview click is an expected
// client-driven outcome (the browser reopens the draft), not a render failure — keep
// it out of the failure telemetry/logging this guard exists to produce.
var outcome = result is ObjectResult { StatusCode: 410 } ? "gone" : "success";
FuchsTelemetry.PdfPreviews.Add(1,
new KeyValuePair<string, object?>("what", what),
new KeyValuePair<string, object?>("outcome", outcome));
_logger.LogInformation("PDF preview {What} ref={RefId} user={User} {Outcome} in {Ms} ms",
what, refId, UserAccountID, outcome, sw.ElapsedMilliseconds);
return result;
}
catch (Exception ex)
{
sw.Stop();
act?.SetStatus(ActivityStatusCode.Error, ex.Message);
// Same client-gone rationale as above, but the render itself threw (e.g. it tried to
// write partial output to the aborted connection). Still an expected outcome of a
// client timeout, not a render defect — logged at Warning, not Error, but still
// guaranteed to reach fds__debuglog (ADR 0024) rather than only a debug console.
if (HttpContext.RequestAborted.IsCancellationRequested)
{
FuchsTelemetry.PdfPreviews.Add(1,
new KeyValuePair<string, object?>("what", what),
new KeyValuePair<string, object?>("outcome", "client-disconnected"));
_logger.LogWarning(ex,
"PDF preview {What} ref={RefId} user={User} failed after {Ms} ms because the client had already disconnected",
what, refId, UserAccountID, sw.ElapsedMilliseconds);
return new EmptyResult();
}
FuchsTelemetry.PdfPreviews.Add(1,
new KeyValuePair<string, object?>("what", what),
new KeyValuePair<string, object?>("outcome", "failure"));
// EventId.Name keeps the exact "PDF preview failed (what)" CodeReference the DB log
// used before the generic SQL sink existed (ADR 0024).
_logger.LogError(new EventId(0, $"{PdfPreviewLogReference} ({what})"), ex,
"PDF preview {What} ref={RefId} user={User} failed after {Ms} ms",
what, refId, UserAccountID, sw.ElapsedMilliseconds);
// Pre-auth flows (some preview endpoints are in _allowedGet, see IntranetController.cs)
// have no user session to notify, matching the guard the Do() catch-all uses.
if (UserIdent.IsAuthenticated)
{
await _events.UserIssueAsync(
"PDF-Vorschau fehlgeschlagen",
"Die PDF-Vorschau konnte nicht erstellt werden. Der Fehler wurde protokolliert.",
UserAccountID,
new Dictionary<string, object?> { ["what"] = what, ["ref"] = refId });
}
return ServerError(PdfPreviewFailureMessage(ex, UserIdent.IsAuthenticated));
}
}
/// <summary>
/// User-facing message. The innermost exception text is only included for an authenticated
/// intranet user — some preview endpoints are reachable unauthenticated (see _allowedGet in
/// IntranetController.cs), so an anonymous caller gets a generic message instead.
/// </summary>
internal static string PdfPreviewFailureMessage(Exception ex, bool includeDetail)
{
if (!includeDetail) return "Die PDF-Vorschau konnte nicht erstellt werden.";
string detail = ex.GetBaseException().Message?.Trim() ?? "";
return detail.Length == 0
? "Die PDF-Vorschau konnte nicht erstellt werden."
: $"Die PDF-Vorschau konnte nicht erstellt werden: {detail}";
}
}
@@ -89,9 +89,11 @@ public partial class IntranetController
_logger.LogWarning("Reminder rdoc: document not found for reminder {RemId} user={User}", Form("id"), UserAccountID); _logger.LogWarning("Reminder rdoc: document not found for reminder {RemId} user={User}", Form("id"), UserAccountID);
return StatusCode(404, new { error = "Dokument wurde nicht gefunden" }); return StatusCode(404, new { error = "Dokument wurde nicht gefunden" });
} }
return Form("typ") != "img" if (Form("typ") != "img")
? await FileContentResultAsync(fc, file.MimeType(), file.Name) return await FileContentResultAsync(fc, file.MimeType(), file.Name);
: await JSONAsync(new { id = Form("id"), img = await BuildPdfImageArray(fc) }); string remId = Form("id");
return await PdfPreviewAsync("rem/rdoc", remId, async () =>
await JSONAsync(new { id = remId, img = await BuildPdfImageArray(fc) }));
} }
case "idoc": return await HandleReminderIdoc(fn, id, code); case "idoc": return await HandleReminderIdoc(fn, id, code);
@@ -153,8 +155,11 @@ public partial class IntranetController
{ {
var remdoc = new Dictionary<string, byte[]> var remdoc = new Dictionary<string, byte[]>
{ [frdic.nz("DocumentName", "").ne("Zahlungserinnerung.pdf")] = filebyte }; { [frdic.nz("DocumentName", "").ne("Zahlungserinnerung.pdf")] = filebyte };
// The invoice copy is optional: an empty varbinary (invoice file never stored) must
// not attach a 0-byte file — the mailer refuses empty attachments and the whole
// reminder would fail instead of going out without the copy.
if (!string.IsNullOrEmpty(frdic.nz("InvoiceFileName")) && if (!string.IsNullOrEmpty(frdic.nz("InvoiceFileName")) &&
frdic.no("InvoiceFile", null!) is byte[] invFile) frdic.no("InvoiceFile", null!) is byte[] { Length: > 0 } invFile)
remdoc[frdic.nz("InvoiceFileName")] = invFile; remdoc[frdic.nz("InvoiceFileName")] = invFile;
_logger.LogInformation("Reminder conf: emailing finalized reminder {RemId} to {Email} user={User}", _logger.LogInformation("Reminder conf: emailing finalized reminder {RemId} to {Email} user={User}",
@@ -206,13 +211,23 @@ public partial class IntranetController
string filename = fdRem.ReminderRegistration!.nz("DocumentName").ne($"Zahlungserinnerung_{fdRem.Id}.pdf"); string filename = fdRem.ReminderRegistration!.nz("DocumentName").ne($"Zahlungserinnerung_{fdRem.Id}.pdf");
if (Form("typ") != "img") if (Form("typ") != "img")
{ {
byte[] ct = Form("create", "0") != "1" byte[]? ct = Form("create", "0") != "1"
? (await _reminders.GetReminderFileAsync(fdRem, fdRem.IsDraft, _mfr, UserAccountID, DbSec)) is { Length: > 0 } f1 ? f1 : await _reminders.StoreReminderDocumentFileAsync(fdRem, fdRem.IsDraft, UserAccountID, DbSec) ? (await _reminders.GetReminderFileAsync(fdRem, fdRem.IsDraft, _mfr, UserAccountID, DbSec)) is { Length: > 0 } f1 ? f1 : await _reminders.StoreReminderDocumentFileAsync(fdRem, fdRem.IsDraft, UserAccountID, DbSec)
: _pdf.DocToPdfBytes(_reminders.GenerateReminderPdf(fdRem, fdRem.IsDraft)); : _pdf.DocToPdfBytes(_reminders.GenerateReminderPdf(fdRem, fdRem.IsDraft));
// StoreReminderDocumentFileAsync signals failure with Array.Empty<byte>() — without this
// guard a failed render/store would be served as a 0-byte "PDF".
if (ct is not { Length: > 0 })
{
_logger.LogError("HandleReminderIdoc: no PDF bytes available for reminder {RemId} user={User}", fdRem.Id, UserAccountID);
return await ReminderIssueResult("Die Mahnungs-PDF konnte aufgrund eines Fehlers nicht erstellt werden.", fdRem.Id);
}
return await FileContentResultAsync(ct, "application/pdf", filename, inline: true); return await FileContentResultAsync(ct, "application/pdf", filename, inline: true);
} }
return await PdfPreviewAsync("rem/idoc", fdRem.Id, async () =>
{
var imgcol = await _pdf.DocToImageCollectionAsync(_reminders.GenerateReminderPdf(fdRem, fdRem.IsDraft)); var imgcol = await _pdf.DocToImageCollectionAsync(_reminders.GenerateReminderPdf(fdRem, fdRem.IsDraft));
return await JSONAsync(new { id = fdRem.Id, img = imgcol.ImgB64Array, total = imgcol.TotalPages }); return await JSONAsync(new { id = fdRem.Id, img = imgcol.ImgB64Array, total = imgcol.TotalPages });
});
} }
private async Task<IActionResult> HandleReminderResend(string fn, string id, string code) private async Task<IActionResult> HandleReminderResend(string fn, string id, string code)
@@ -235,8 +250,11 @@ public partial class IntranetController
{ {
var remdoc = new Dictionary<string, byte[]> var remdoc = new Dictionary<string, byte[]>
{ [frdic.nz("DocumentName", "").ne("Zahlungserinnerung.pdf")] = filebyte }; { [frdic.nz("DocumentName", "").ne("Zahlungserinnerung.pdf")] = filebyte };
// The invoice copy is optional: an empty varbinary (invoice file never stored) must
// not attach a 0-byte file — the mailer refuses empty attachments and the whole
// reminder would fail instead of going out without the copy.
if (!string.IsNullOrEmpty(frdic.nz("InvoiceFileName")) && if (!string.IsNullOrEmpty(frdic.nz("InvoiceFileName")) &&
frdic.no("InvoiceFile", null!) is byte[] invFile) frdic.no("InvoiceFile", null!) is byte[] { Length: > 0 } invFile)
remdoc[frdic.nz("InvoiceFileName")] = invFile; remdoc[frdic.nz("InvoiceFileName")] = invFile;
bool sent = await _comService.SendEmailAsync($"rem_{remId}", bool sent = await _comService.SendEmailAsync($"rem_{remId}",
$"SanitärFuchs - {frdic.nz("subject").ne(frdic.nz("DocumentName"))}", $"SanitärFuchs - {frdic.nz("subject").ne(frdic.nz("DocumentName"))}",
@@ -67,10 +67,14 @@ public partial class IntranetController
private async Task<IActionResult> HandleReminderDraftPreview(string fn, string id, string code) private async Task<IActionResult> HandleReminderDraftPreview(string fn, string id, string code)
{ {
if (!HasForm("token")) return BadRequest400(); if (!HasForm("token")) return BadRequest400();
var doc = _reminderDrafts.RenderPreview(Form("token")); string token = Form("token");
return await PdfPreviewAsync("rem/dpreview", token, async () =>
{
var doc = _reminderDrafts.RenderPreview(token);
if (doc == null) return DraftGone(); if (doc == null) return DraftGone();
var imgcol = await _pdf.DocToImageCollectionAsync(doc); var imgcol = await _pdf.DocToImageCollectionAsync(doc);
return await JSONAsync(new { img = imgcol.ImgB64Array, total = imgcol.TotalPages }); return await JSONAsync(new { img = imgcol.ImgB64Array, total = imgcol.TotalPages });
});
} }
// POST rem/dsave — { token } → { ok, remid }; flush cache→DB + business event + draftReady // POST rem/dsave — { token } → { ok, remid }; flush cache→DB + business event + draftReady
@@ -78,16 +82,14 @@ public partial class IntranetController
{ {
if (!HasForm("token")) return BadRequest400(); if (!HasForm("token")) return BadRequest400();
string token = Form("token"); string token = Form("token");
var before = _reminderDrafts.Get(token); // Create vs update is decided inside the flush (serialised per session) — see HandleDraftSave.
if (before == null) return DraftGone(); var flush = await _reminderDrafts.FlushToDbAsync(token, UserAccountID, DbSec);
bool existed = !string.IsNullOrEmpty(before.RemId); if (flush == null) return DraftGone();
var fdRem = flush.Data;
var fdRem = await _reminderDrafts.FlushToDbAsync(token, UserAccountID, DbSec);
if (fdRem == null) return DraftGone();
if (string.IsNullOrEmpty(fdRem.Id)) if (string.IsNullOrEmpty(fdRem.Id))
return await ReminderIssueResult("Der Zwischenstand konnte aufgrund eines Fehlers nicht gespeichert werden."); return await ReminderIssueResult("Der Zwischenstand konnte aufgrund eines Fehlers nicht gespeichert werden.");
await _events.ReminderDraftRegisteredAsync(fdRem, existed, UserAccountID); await _events.ReminderDraftRegisteredAsync(fdRem, flush.Updated, UserAccountID);
var after = _reminderDrafts.Get(token); var after = _reminderDrafts.Get(token);
if (after != null) await _draftNotifier.SignalDraftReadyAsync(after.Token, after.Version); if (after != null) await _draftNotifier.SignalDraftReadyAsync(after.Token, after.Version);
return await JSONAsync(new { ok = true, remid = fdRem.Id }); return await JSONAsync(new { ok = true, remid = fdRem.Id });
@@ -1,5 +1,6 @@
using System.Globalization; using System.Globalization;
using Fuchs.intranet; using Fuchs.intranet;
using Fuchs.Observability;
using Fuchs.Services; using Fuchs.Services;
using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc;
using Microsoft.Data.SqlClient; using Microsoft.Data.SqlClient;
@@ -75,7 +76,7 @@ public partial class IntranetController
{ {
_logger.LogInformation("Requests sprep: invoice draft {InvId} created, user={User}", fdInv.Id, UserAccountID); _logger.LogInformation("Requests sprep: invoice draft {InvId} created, user={User}", fdInv.Id, UserAccountID);
await _events.InvoiceDraftRegisteredAsync(fdInv, changed: false, userAccountId: UserAccountID); await _events.InvoiceDraftRegisteredAsync(fdInv, changed: false, userAccountId: UserAccountID);
var imgcol = await _pdf.DocToImageCollectionAsync(_invoices.GenerateInvoicePdf(fdInv, fdInv.IsDraft)); var imgcol = await _pdf.DocToImageCollectionAsync(await _invoices.GenerateInvoicePdfAsync(fdInv, fdInv.IsDraft));
return await JSONAsync(new { id = fdInv.Id, img = imgcol.ImgB64Array, total = imgcol.TotalPages }); return await JSONAsync(new { id = fdInv.Id, img = imgcol.ImgB64Array, total = imgcol.TotalPages });
} }
return await InvoiceIssueResult("Die Rechnung konnte aufgrund eines Fehlers nicht erstellt werden."); return await InvoiceIssueResult("Die Rechnung konnte aufgrund eines Fehlers nicht erstellt werden.");
@@ -92,7 +93,7 @@ public partial class IntranetController
{ {
_logger.LogInformation("Requests sedit: invoice draft {InvId} updated, user={User}", fdInv.Id, UserAccountID); _logger.LogInformation("Requests sedit: invoice draft {InvId} updated, user={User}", fdInv.Id, UserAccountID);
await _events.InvoiceDraftRegisteredAsync(fdInv, changed: true, userAccountId: UserAccountID); await _events.InvoiceDraftRegisteredAsync(fdInv, changed: true, userAccountId: UserAccountID);
var imgcol = await _pdf.DocToImageCollectionAsync(_invoices.GenerateInvoicePdf(fdInv, fdInv.IsDraft)); var imgcol = await _pdf.DocToImageCollectionAsync(await _invoices.GenerateInvoicePdfAsync(fdInv, fdInv.IsDraft));
return await JSONAsync(new { id = fdInv.Id, img = imgcol.ImgB64Array, total = imgcol.TotalPages }); return await JSONAsync(new { id = fdInv.Id, img = imgcol.ImgB64Array, total = imgcol.TotalPages });
} }
return await InvoiceIssueResult("Die Rechnung konnte aufgrund eines Fehlers nicht aktualisiert werden."); return await InvoiceIssueResult("Die Rechnung konnte aufgrund eines Fehlers nicht aktualisiert werden.");
@@ -302,14 +303,41 @@ public partial class IntranetController
private async Task<IActionResult> HandleRequestSconf(string fn, string id, string code) private async Task<IActionResult> HandleRequestSconf(string fn, string id, string code)
{ {
if (!HasForm("id")) { _logger.LogWarning("HandleRequestSconf: missing form field 'id', user={User}", UserAccountID); return BadRequest400(); } if (!HasForm("id")) { _logger.LogWarning("HandleRequestSconf: missing form field 'id', user={User}", UserAccountID); return BadRequest400(); }
_logger.LogInformation("HandleRequestSconf: finalizing invoice {InvId} user={User}", Form("id"), UserAccountID); // Admin option: date the invoice to the last working day of the previous month (ADR 0032).
// fds__setInvoiceFinal re-validates (fds_inv > 2, no later invoice) and throws otherwise.
bool backdate = InvoiceDates.IsBackdateRequested(Form("backdate"));
_logger.LogInformation("HandleRequestSconf: finalizing invoice {InvId} backdate={Backdate} user={User}", Form("id"), backdate, UserAccountID);
var dt = await getSQLDatatable_async( var dt = await getSQLDatatable_async(
"EXECUTE [dbo].[fds__setInvoiceFinal] @Id, @authuser;", "EXECUTE [dbo].[fds__setInvoiceFinal] @Id, @authuser, @backdate;",
_intranet.Intranet__SQLConnectionString, _intranet.Intranet__SQLConnectionString,
StdParamlist(SQL_VarChar("@Id", Form("id"))), StdParamlist(SQL_VarChar("@Id", Form("id")), SQL_Bit("@backdate", backdate)),
Security: DbSec, options: SqlOpt(fn, id, code)); Security: DbSec, options: SqlOpt(fn, id, code));
if (!string.IsNullOrEmpty(dt.Exception)) if (!string.IsNullOrEmpty(dt.Exception))
_logger.LogError("HandleRequestSconf: SQL error finalizing invoice {InvId}: {SqlError}, user={User}", Form("id"), dt.Exception, UserAccountID); _logger.LogError("HandleRequestSconf: SQL error {SqlErrorNumber} finalizing invoice {InvId}: {SqlError}, user={User}", dt.ExceptionCode, Form("id"), dt.Exception, UserAccountID);
// Only SQL error 60001 is a backdate rejection. A 0-row result means the invoice is already
// final/sent (double submit) — it must not be reported as "create it again without backdating".
var outcome = InvoiceDates.ClassifyFinalizeResult(dt.Exception, dt.ExceptionCode, dt.Count);
if (backdate)
{
FuchsTelemetry.InvoicesBackdated.Add(1, new KeyValuePair<string, object?>("outcome", InvoiceDates.OutcomeTag(outcome)));
if (outcome == InvoiceFinalizeOutcome.Finalized)
_logger.LogInformation(new EventId(0, "InvoiceBackdated"),
"Invoice {InvId} finalized with backdated invoice date {DateFinalized} user={User}",
Form("id"), dt.FirstRow.toObjectDictionary().nz("DateFinalized"), UserAccountID);
}
switch (outcome)
{
case InvoiceFinalizeOutcome.BackdateNotAllowed:
return await InvoiceIssueResult(
"Die Rechnung wurde nicht erstellt: Das Rechnungsdatum kann nicht mehr auf den Vormonat gesetzt werden (es wurde bereits eine Rechnung im neuen Monat erstellt). Bitte ohne Rückdatierung erneut erstellen.",
Form("id"));
case InvoiceFinalizeOutcome.AlreadyFinal:
return await InvoiceIssueResult(
"Die Rechnung ist bereits abgeschlossen oder versendet und wurde nicht erneut erstellt.",
Form("id"));
case InvoiceFinalizeOutcome.Error:
return await InvoiceIssueResult("Die Rechnung konnte aufgrund eines Fehlers nicht erstellt werden.", Form("id"));
}
var frdic = dt.FirstRow.toObjectDictionary(); var frdic = dt.FirstRow.toObjectDictionary();
if (frdic.TryGetValue("IsFinal", out var isFinal) && isFinal is true) if (frdic.TryGetValue("IsFinal", out var isFinal) && isFinal is true)
{ {
@@ -357,7 +385,7 @@ public partial class IntranetController
else if (filebyte.Length == 0) else if (filebyte.Length == 0)
{ {
_logger.LogError( _logger.LogError(
"Invoice PDF render returned 0 bytes — invoiceId={InvoiceId} user={User}", "Invoice PDF could not be rendered or stored (see preceding InvoiceService/SQL error) — invoiceId={InvoiceId} user={User}",
invId, UserAccountID); invId, UserAccountID);
await _events.InvoiceIssueAsync( await _events.InvoiceIssueAsync(
$"Die Rechnungs-PDF {frdic.nz("DocumentName").ne($"Rechnung_{invId}.pdf")} konnte nicht erstellt werden.", $"Die Rechnungs-PDF {frdic.nz("DocumentName").ne($"Rechnung_{invId}.pdf")} konnte nicht erstellt werden.",
@@ -387,7 +415,7 @@ public partial class IntranetController
private async Task<IActionResult> HandleRequestIdoc(string fn, string id, string code) private async Task<IActionResult> HandleRequestIdoc(string fn, string id, string code)
{ {
if (!HasForm("id") || string.IsNullOrEmpty(Form("id"))) { _logger.LogWarning("HandleRequestIdoc: missing/empty form field 'id', user={User}", UserAccountID); return StatusCode(404); } if (!HasForm("id")) { _logger.LogWarning("HandleRequestIdoc: missing form field 'id', user={User}", UserAccountID); return StatusCode(404); }
_logger.LogDebug("HandleRequestIdoc: invoiceId={InvId} typ={Typ} create={Create} user={User}", Form("id"), Form("typ"), Form("create", "0"), UserAccountID); _logger.LogDebug("HandleRequestIdoc: invoiceId={InvId} typ={Typ} create={Create} user={User}", Form("id"), Form("typ"), Form("create", "0"), UserAccountID);
var fdInv = await _invoices.LoadInvoiceAsync(Form("id"), UserAccountID, DbSec); var fdInv = await _invoices.LoadInvoiceAsync(Form("id"), UserAccountID, DbSec);
if (string.IsNullOrEmpty(fdInv.Id)) { _logger.LogWarning("HandleRequestIdoc: invoice not found id={InvId} user={User}", Form("id"), UserAccountID); return StatusCode(404, new { error = "Rechnung wurde nicht gefunden" }); } if (string.IsNullOrEmpty(fdInv.Id)) { _logger.LogWarning("HandleRequestIdoc: invoice not found id={InvId} user={User}", Form("id"), UserAccountID); return StatusCode(404, new { error = "Rechnung wurde nicht gefunden" }); }
@@ -396,17 +424,25 @@ public partial class IntranetController
{ {
byte[]? ct = Form("create", "0") != "1" byte[]? ct = Form("create", "0") != "1"
? await _invoices.GetInvoiceFileAsync(fdInv, fdInv.IsDraft, _mfr) is { Length: > 0 } f1 ? f1 : await _invoices.StoreInvoiceDocumentFileAsync(fdInv, fdInv.IsDraft, UserAccountID, DbSec) ? await _invoices.GetInvoiceFileAsync(fdInv, fdInv.IsDraft, _mfr) is { Length: > 0 } f1 ? f1 : await _invoices.StoreInvoiceDocumentFileAsync(fdInv, fdInv.IsDraft, UserAccountID, DbSec)
: _pdf.DocToPdfBytes(_invoices.GenerateInvoicePdf(fdInv, fdInv.IsDraft)); : _pdf.DocToPdfBytes(await _invoices.GenerateInvoicePdfAsync(fdInv, fdInv.IsDraft));
if (ct == null) // StoreInvoiceDocumentFileAsync signals failure with Array.Empty<byte>(), not null — a
// bare `ct == null` check never catches that and would silently serve a 0-byte "PDF".
if (ct is not { Length: > 0 })
{
_logger.LogError("HandleRequestIdoc: no PDF bytes available for invoice {InvId} user={User}", fdInv.Id, UserAccountID);
return await InvoiceIssueResult("Die Rechnungs-PDF konnte aufgrund eines Fehlers nicht erstellt werden.", fdInv.Id); return await InvoiceIssueResult("Die Rechnungs-PDF konnte aufgrund eines Fehlers nicht erstellt werden.", fdInv.Id);
}
// Serve inline for the in-browser viewer, but carry the real DocumentName so the browser's // Serve inline for the in-browser viewer, but carry the real DocumentName so the browser's
// "download" uses "Rechnung R2026-0121.pdf" instead of the "idoc" endpoint segment. (The // "download" uses "Rechnung R2026-0121.pdf" instead of the "idoc" endpoint segment. (The
// OCORE FileContentResult helper drops the filename under ASP.NET Core, so set it here.) // OCORE FileContentResult helper drops the filename under ASP.NET Core, so set it here.)
SetInlinePdfFilename(filename); SetInlinePdfFilename(filename);
return File(ct, "application/pdf"); return File(ct, "application/pdf");
} }
var imgcol = await _pdf.DocToImageCollectionAsync(_invoices.GenerateInvoicePdf(fdInv, fdInv.IsDraft)); return await PdfPreviewAsync("req/idoc", fdInv.Id, async () =>
{
var imgcol = await _pdf.DocToImageCollectionAsync(await _invoices.GenerateInvoicePdfAsync(fdInv, fdInv.IsDraft));
return await JSONAsync(new { id = fdInv.Id, img = imgcol.ImgB64Array, total = imgcol.TotalPages }); return await JSONAsync(new { id = fdInv.Id, img = imgcol.ImgB64Array, total = imgcol.TotalPages });
});
} }
private async Task<IActionResult> HandleRequestResend(string fn, string id, string code) private async Task<IActionResult> HandleRequestResend(string fn, string id, string code)
@@ -0,0 +1,35 @@
using Microsoft.AspNetCore.Mvc;
using static OCORE.web.mvc_helper_async;
namespace Fuchs.Controllers;
// Partial class: "Specialties" module — the admin page (URL /specialties, menu entry under
// Administration) that surfaces the otherwise hidden maintenance actions as small card forms.
//
// This partial only answers the module's auth probe; the cards call the real action endpoints
// directly (e.g. POST /do/admin/backupclearinvoicefile/<id> in IntranetController.Admin.cs),
// each of which enforces the same fds_sys > 4 gate server-side. See
// Docs/Concepts/admin-specialties.md.
public partial class IntranetController
{
private async Task<IActionResult> Do_Process_Specialties(string fn, string id, string code)
{
_logger.LogDebug("Do_Process_Specialties action={Action} user={User}", id, UserAccountID);
int authLevel = await GetSystemAdminAuthAsync(fn, id, code);
bool authorized = authLevel > AdminMinAuthExclusive;
if (id.Equals("auth", StringComparison.OrdinalIgnoreCase))
return await JSONAsync(new { manage = authorized ? 1 : 0, level = authLevel });
if (!authorized)
{
_logger.LogWarning("Specialties access denied for user={User} (fds_sys={Level}) action={Action}",
UserAccountID, authLevel, id);
return Unauthorized401();
}
_logger.LogWarning("Specialties: no handler for action={Action}, user={User}", id, UserAccountID);
return BadRequest400();
}
}
+37 -7
View File
@@ -33,6 +33,7 @@ public partial class IntranetController : Microsoft.AspNetCore.Mvc.Controller
private readonly IWidgetService _widgets; private readonly IWidgetService _widgets;
private readonly IReportService _reports; private readonly IReportService _reports;
private readonly IInvoiceService _invoices; private readonly IInvoiceService _invoices;
private readonly IInvoiceFileBackupService _invoiceFileBackup;
private readonly IReminderService _reminders; private readonly IReminderService _reminders;
private readonly IEventService _events; private readonly IEventService _events;
private readonly IInvoiceDraftService _invoiceDrafts; private readonly IInvoiceDraftService _invoiceDrafts;
@@ -65,6 +66,7 @@ public partial class IntranetController : Microsoft.AspNetCore.Mvc.Controller
IWidgetService widgets, IWidgetService widgets,
IReportService reports, IReportService reports,
IInvoiceService invoices, IInvoiceService invoices,
IInvoiceFileBackupService invoiceFileBackup,
IReminderService reminders, IReminderService reminders,
IEventService events, IEventService events,
IInvoiceDraftService invoiceDrafts, IInvoiceDraftService invoiceDrafts,
@@ -82,6 +84,7 @@ public partial class IntranetController : Microsoft.AspNetCore.Mvc.Controller
_widgets = widgets; _widgets = widgets;
_reports = reports; _reports = reports;
_invoices = invoices; _invoices = invoices;
_invoiceFileBackup = invoiceFileBackup;
_reminders = reminders; _reminders = reminders;
_events = events; _events = events;
_invoiceDrafts = invoiceDrafts; _invoiceDrafts = invoiceDrafts;
@@ -123,7 +126,25 @@ public partial class IntranetController : Microsoft.AspNetCore.Mvc.Controller
// ── Action helpers ──────────────────────────────────────────────────────── // ── Action helpers ────────────────────────────────────────────────────────
protected IActionResult Unauthorized401() => StatusCode(401); protected IActionResult Unauthorized401() => StatusCode(401);
protected IActionResult BadRequest400() => BadRequest(); protected IActionResult BadRequest400() => BadRequest();
protected IActionResult ServerError(string msg = "") => StatusCode(500, new { error = msg }); /// <summary>
/// 500 with the message both in the JSON body and in the <c>x-ocms-desc</c> header, which is
/// the only place the frontend's default failure handler (<c>$ocms.failure</c>) reads a
/// description from — without it the user only ever saw an empty "Der Server hat einen Fehler
/// gemeldet:" alert.
/// </summary>
protected IActionResult ServerError(string msg = "")
{
if (!string.IsNullOrEmpty(msg))
Response.Headers["x-ocms-desc"] = EncodeResponseDescription(msg);
return StatusCode(500, new { error = msg });
}
/// <summary>
/// Header-safe (ASCII, percent-encoded) form of a user message; the client decodes it with
/// <c>decodeURIComponent</c> after mapping <c>+</c> to a space, so spaces must be <c>%20</c>.
/// </summary>
internal static string EncodeResponseDescription(string msg) =>
Uri.EscapeDataString((msg ?? "").Replace("\r", " ").Replace("\n", " ").Trim());
// ── Index (GET /) ───────────────────────────────────────────────────────── // ── Index (GET /) ─────────────────────────────────────────────────────────
[AllowAnonymous] [AllowAnonymous]
@@ -167,7 +188,9 @@ public partial class IntranetController : Microsoft.AspNetCore.Mvc.Controller
"rep" => await Do_Process_Reports(fn, id, code), "rep" => await Do_Process_Reports(fn, id, code),
"bam" => await Do_Process_Bankings(fn, id, code), "bam" => await Do_Process_Bankings(fn, id, code),
"admin" => await Do_Process_Admin(fn, id, code), "admin" => await Do_Process_Admin(fn, id, code),
"specialties" => await Do_Process_Specialties(fn, id, code),
"usr" => await Do_Process_AdminUsers(fn, id, code), "usr" => await Do_Process_AdminUsers(fn, id, code),
"usrgrp" => await Do_Process_AdminGroups(fn, id, code),
"auth" => await HandleAuth(fn, id, code), "auth" => await HandleAuth(fn, id, code),
"spwc" => await HandleSendPasswordCode(fn, id, code), "spwc" => await HandleSendPasswordCode(fn, id, code),
"spw" => await HandleSendPassword(fn, id, code), "spw" => await HandleSendPassword(fn, id, code),
@@ -186,8 +209,11 @@ public partial class IntranetController : Microsoft.AspNetCore.Mvc.Controller
} }
catch (Exception ex) catch (Exception ex)
{ {
_logger.LogError(ex, "Unhandled exception in Do fn={Fn} id={Id} code={Code} user={User}", // EventId.Name keeps the exact CodeReference the DB log used before the generic SQL
fn, id, code, UserAccountID); // sink existed (ADR 0024); formKeys is form field NAMES only, never values (PII/tokens).
_logger.LogError(new EventId(0, $"Unhandled exception in Do {fn}/{id}"), ex,
"Unhandled exception in Do fn={Fn} id={Id} code={Code} user={User} formKeys={FormKeys}",
fn, id, code, UserAccountID, RequestFormKeys());
// Standing rule: an exception that interrupts a user-initiated action must reach the // Standing rule: an exception that interrupts a user-initiated action must reach the
// user as a notification, not only the log. This is the catch-all safety net for any // user as a notification, not only the log. This is the catch-all safety net for any
// Do_Process_* action that throws without first publishing its own (more specific) // Do_Process_* action that throws without first publishing its own (more specific)
@@ -201,10 +227,14 @@ public partial class IntranetController : Microsoft.AspNetCore.Mvc.Controller
UserAccountID, UserAccountID,
new Dictionary<string, object?> { ["fn"] = fn }); new Dictionary<string, object?> { ["fn"] = fn });
} }
return ServerError(); return ServerError("Die Aktion konnte aufgrund eines unerwarteten Fehlers nicht abgeschlossen werden.");
} }
} }
/// <summary>Form field names of the current request (never values — they may carry PII/tokens).</summary>
private string[] RequestFormKeys() =>
Request.HasFormContentType ? Request.Form.Keys.ToArray() : Array.Empty<string>();
// ── Auth helper ─────────────────────────────────────────────────────────── // ── Auth helper ───────────────────────────────────────────────────────────
private async Task<IActionResult> HandleAuth(string fn, string id, string code) private async Task<IActionResult> HandleAuth(string fn, string id, string code)
{ {
@@ -249,16 +279,16 @@ public partial class IntranetController : Microsoft.AspNetCore.Mvc.Controller
_logger.LogDebug("HandleLogin attempt for email={Email} ip={IP}", _logger.LogDebug("HandleLogin attempt for email={Email} ip={IP}",
email, HttpContext.Connection.RemoteIpAddress); email, HttpContext.Connection.RemoteIpAddress);
var row = await _intranet.AuthenticateAsync(email, password); var row = await _intranet.AuthenticateAsync(email, password);
if (row == null) string userId = row?.nz("useraccount_id") ?? "";
if (row == null || string.IsNullOrEmpty(userId))
{ {
_logger.LogWarning("Login failed for email={Email} ip={IP}", _logger.LogWarning("Login failed for email={Email} ip={IP}",
email, HttpContext.Connection.RemoteIpAddress); email, HttpContext.Connection.RemoteIpAddress);
return Unauthorized401(); return Unauthorized401();
} }
string userId = row.nz("useraccount_id");
string userEmail = row.nz("email"); string userEmail = row.nz("email");
int auth = int.TryParse(row.nz("authorization"), out var a) ? a : 0; int auth = int.TryParse(row.nz("auth"), out var a) ? a : 0;
var identity = FuchsUserIdentity.BuildIdentity(userId, userEmail, auth, Fuchs_intranet.AuthScheme); var identity = FuchsUserIdentity.BuildIdentity(userId, userEmail, auth, Fuchs_intranet.AuthScheme);
var principal = new System.Security.Claims.ClaimsPrincipal(identity); var principal = new System.Security.Claims.ClaimsPrincipal(identity);
+4 -3
View File
@@ -178,7 +178,7 @@ Stateless services (`IPdfService`, `IBankingService`, `IMfrClientFactory`) are s
The **Admin** module (`Do_Process_Admin`, `ISystemStatusService`) surfaces a live system-status/diagnostics page (host, SQL/Key Vault/blob/MFR connectivity, email config, test-email) restricted to `fds_sys` > 4 — see ADR [0011](Decisions/0011-admin-module-system-status.md) and the [concept doc](Concepts/admin-system-status.md). The **Admin** module (`Do_Process_Admin`, `ISystemStatusService`) surfaces a live system-status/diagnostics page (host, SQL/Key Vault/blob/MFR connectivity, email config, test-email) restricted to `fds_sys` > 4 — see ADR [0011](Decisions/0011-admin-module-system-status.md) and the [concept doc](Concepts/admin-system-status.md).
`FdsInvoiceData` / `FdsReminderData` are now **pure data holders** (parse + properties); loading, persistence and PDF generation live in the services (fully async — no `Task.Run(...).Wait()`). `FdsInvoiceData` / `FdsReminderData` are now **pure data holders** (parse + properties); loading, persistence and PDF generation live in the services (fully async — no `Task.Run(...).Wait()`).
Invoice/reminder drafts are edited **backend-authoritatively**: the truth is an in-memory `InvoiceDraftSession` mutated by single deltas (`IInvoiceDraftService`), including the user-defined **set structure** (`InvoiceSetStructure` — parent links, nesting, levels; ADR [0016](Decisions/0016-user-defined-set-structure.md)) and the set-price operations (`InvoiceSetPricing`, ADR [0009](Decisions/0009-block-setprice-modes-are-irreversible-mutations.md)). See the [live draft editing concept](Concepts/live-draft-editing.md) and ADRs [0006](Decisions/0006-backend-authoritative-draft-editing.md)/[0008](Decisions/0008-invoices-and-reminders-fully-backend-authoritative.md). Invoice/reminder drafts are edited **backend-authoritatively**: the truth is an in-memory `InvoiceDraftSession` mutated by single deltas (`IInvoiceDraftService`), including the user-defined **set structure** (`InvoiceSetStructure` — parent links, nesting, levels; ADR [0016](Decisions/0016-user-defined-set-structure.md)) and the set-price operations (`InvoiceSetPricing`, ADR [0019](Decisions/0019-set-price-modes-are-keyed-on-the-set.md)). See the [live draft editing concept](Concepts/live-draft-editing.md) and ADRs [0006](Decisions/0006-backend-authoritative-draft-editing.md)/[0008](Decisions/0008-invoices-and-reminders-fully-backend-authoritative.md).
`FuchsPdf` / `FuchsVisualization` remain as static rendering libraries used *by* the services. The earlier static, controller-coupled helpers (`FuchsWidgets`, `FuchsReports`, `Banking`, `FuchsFdsEmail`) have been removed. `FuchsPdf` / `FuchsVisualization` remain as static rendering libraries used *by* the services. The earlier static, controller-coupled helpers (`FuchsWidgets`, `FuchsReports`, `Banking`, `FuchsFdsEmail`) have been removed.
### 4.4 SQL-First Data Access ### 4.4 SQL-First Data Access
@@ -196,8 +196,8 @@ Cookie-based authentication (`CookieAuthenticationDefaults`) with custom claims
> **Status: DONE.** The services below are implemented and DI-registered in > **Status: DONE.** The services below are implemented and DI-registered in
> `Program.cs`. The original extraction rationale is retained for reference / > `Program.cs`. The original extraction rationale is retained for reference /
> history. `FuchsFdsEmail` → `IComService` (ProcessWeb Mailer API, inline > history. `FuchsFdsEmail` → `IComService` (ProcessWeb Mailer API, multipart
> base64 attachments), `FuchsWidgets` → `IWidgetService`, `FuchsPdf` → > attachments — see `Docs/Concepts/processweb-mailer-interface.md`), `FuchsWidgets` → `IWidgetService`, `FuchsPdf` →
> `IPdfService`, `Banking` → `IBankingService` (now MT940 **and** CAMT), > `IPdfService`, `Banking` → `IBankingService` (now MT940 **and** CAMT),
> `FdsInvoiceData`/`FdsReminderData` → `IInvoiceService`/`IReminderService` > `FdsInvoiceData`/`FdsReminderData` → `IInvoiceService`/`IReminderService`
> (data classes are now pure POCOs), `FuchsReports` → `IReportService` > (data classes are now pure POCOs), `FuchsReports` → `IReportService`
@@ -446,3 +446,4 @@ public class MfrClientFactory : IMfrClientFactory, IDisposable
- The SQL schema lives in the **`Fuchs_Database`** SSDT project (source of truth). The backend is SQL-first: it calls stored procedures, table-valued types (e.g. `fds__tt__bankingtransactions`) and functions via OCORE ADO.NET helpers. - The SQL schema lives in the **`Fuchs_Database`** SSDT project (source of truth). The backend is SQL-first: it calls stored procedures, table-valued types (e.g. `fds__tt__bankingtransactions`) and functions via OCORE ADO.NET helpers.
- When changing a stored procedure's name/parameters or a table type, update both the SSDT project and the calling C# in the same change, and keep the MT940/CAMT banking column mappings aligned with `fds__tt__bankingtransactions`. - When changing a stored procedure's name/parameters or a table type, update both the SSDT project and the calling C# in the same change, and keep the MT940/CAMT banking column mappings aligned with `fds__tt__bankingtransactions`.
- The SSDT project is the compile step for all T-SQL: it references the `master` system dacpac and treats T-SQL warnings as errors (`TreatTSqlWarningsAsErrors`, ADR 0036), so an unresolved or ambiguous reference fails `msbuild Fuchs_Database/FuchsDatabase.sqlproj` instead of surfacing at runtime.
@@ -0,0 +1,93 @@
---
status: Active
lastUpdated: 2026-09-16
applyTo:
- "Fuchs/Controllers/IntranetController.AdminGroups.cs"
- "Fuchs/js/intranet/modules/fis.usrgrp*.js"
- "Fuchs/js/intranet/modules/fis.usrgrp.scss"
- "Fuchs/js/intranet/fis_main_menu.js"
- "Fuchs_Database/dbo/Functions/fis_admin_getUserGroupList.sql"
- "Fuchs_Database/dbo/Stored Procedures/fis_admin_setUserGroup.sql"
- "Fuchs_Database/dbo/Stored Procedures/fis_admin_delUserGroup.sql"
relatedDecisions:
- "0021-user-group-management.md"
- "0014-admin-user-management.md"
- "0015-split-admin-user-management-module.md"
---
# Group Management module (`usrgrp`)
## Summary
The **Gruppenverwaltung** module lets a privileged operator see every non-deleted usergroup,
create a new one, rename it, and delete it. It's the catalog-management counterpart to the `usr`
module's per-user "Gruppen" dialog, which toggles an individual user's membership in these groups
but cannot create/rename/delete the groups themselves. Same sidemenu-visibility and server-side
authorization gate as `usr`/`admin` (`fds_sys > 4`), own `fn=usrgrp`, own frontend bundle, own
controller partial. See [0021](../Decisions/0021-user-group-management.md) for why this is a
separate module rather than folded into `usr`.
## How it works
### Authorization (two independent axes — same as `usr`, read [0014](../Decisions/0014-admin-user-management.md) first)
1. **Menu visibility + `fds_sys` gate** — `fis_main_menu.js#addAdminMenuButtonsIfAuthorized`
checks `fds_sys > 4` once and, if authorized, adds the "Administration" sidemenu dropdown with
three entries: Benutzerverwaltung (`init:usr`), Gruppenverwaltung (`init:usrgrp`) and
Systemüberwachung (`init:admin`). Every `/do/usrgrp/*` endpoint re-checks `fds_sys > 4`
server-side (`Do_Process_AdminGroups`, reusing `AdminMinAuthExclusive`/`GetSystemAdminAuthAsync`
from `IntranetController.Admin.cs`) and 401s otherwise.
2. **Separate SQL-layer gate** — every `fis_admin_*` proc/function called below independently
checks the caller's `intranet_admin` setting via `fis_admin_getUserAuth(@authaccount) > 1`. An
account with `fds_sys > 4` but no `intranet_admin` setting reaches every `usrgrp` endpoint but
gets empty results / silent no-op writes — not an error. This also applies to
`fis_admin_addGroupMember`/`remGroupMember` (used by the `usr` module's membership dialog,
documented there) — all five group-related `fis_admin_*` write/read objects now carry this
check as of [0021](../Decisions/0021-user-group-management.md), which closed a real gap where
three of them had none.
### Request flow
```
click "Gruppenverwaltung" → $ocms.init('usrgrp')
POST /do/usrgrp/auth → { manage, level } (manage>0 ⇒ authorized)
load /web/fis.usrgrp.de.js + /web/fis.usrgrp.css
$ocms.usrgrp.init2() → init3() → renderLst()
POST /do/usrgrp/list → { groups: [...] } every non-deleted group
base ribbon (eM) → "Anlegen" (always available)
POST /do/usrgrp/create {name} → { ok, usergroup_id, name }
row click → per-row ribbon (iMn): Umbenennen / Löschen
POST /do/usrgrp/rename {usergroup_id, name} → { ok, usergroup_id, name }
POST /do/usrgrp/delete {usergroup_id} → { ok }
```
### Backend (`IntranetController.AdminGroups.cs`)
- `list` → `fis_admin_getUserGroupList(@authaccount, NULL)`, all non-deleted groups with
decrypted names.
- `create`/`rename` → both call `fis_admin_setUserGroup(@authaccount, @usergroup_id, @name,
@enc_key)`; `create` passes `@usergroup_id = NULL` (the proc generates a new id via
`fis_fn_id`), `rename` passes the existing id.
- `delete` → `fis_admin_delUserGroup(@authaccount, @usergroup_id)` (soft-delete: sets
`UserDeleted`/`DateDeleted`). No check for remaining members — a deleted group's membership
rows simply stop being returned by `fis_admin_getUserGroupMembership` (filtered on the group's
own `DateDeleted`), see [0021](../Decisions/0021-user-group-management.md) Consequences.
### Frontend (`fis.usrgrp.js` + `fis.usrgrp_txt_de.js` + `fis.usrgrp.scss`)
Same list+ribbon pattern as `fis.usr.js`: `renderLst` builds a single-column card-style row list;
a row click toggles `.selected` and swaps the `#topbar` ribbon between the base ribbon (`eM`:
"Anlegen") and the per-row actions (`iMn`: Umbenennen/Löschen). `createDlg`/`renameDlg` use
`$ocms.dlgform`; `deleteGroup` follows the `confirm(...)`-before-destructive-action pattern (no
dialog, nothing to collect beyond the confirmation).
## Key files
- `Fuchs/Controllers/IntranetController.AdminGroups.cs` — `Do_Process_AdminGroups` dispatch.
- `Fuchs/Controllers/IntranetController.Admin.cs` — shared `AdminMinAuthExclusive`/
`GetSystemAdminAuthAsync`/`AdminJson` this module reuses.
- `Fuchs/js/intranet/modules/fis.usrgrp.js`, `fis.usrgrp_txt_de.js`, `fis.usrgrp.scss` — the
module, texts, styles (bundled via `bdlconfig.json`, `intranet:usrgrp` context).
- `Fuchs/js/intranet/fis_main_menu.js` (`addAdminMenuButtonsIfAuthorized`) — sidemenu wiring.
- `Fuchs_Database/dbo/Functions/fis_admin_getUserGroupList.sql` — read-side group query.
- `Fuchs_Database/dbo/Stored Procedures/fis_admin_setUserGroup.sql`,
`fis_admin_delUserGroup.sql` — write-side group procs.
## Related decisions
- [0021 — User group management (new module, closed the missing intranet_admin gate)](../Decisions/0021-user-group-management.md)
- [0014 — Admin user management (dual auth axis, original decisions)](../Decisions/0014-admin-user-management.md)
- [0015 — Split Admin into separate Status/User-Management modules](../Decisions/0015-split-admin-user-management-module.md)
+81
View File
@@ -0,0 +1,81 @@
---
status: Active
lastUpdated: 2026-09-17
applyTo:
- "Fuchs/Controllers/IntranetController.Specialties.cs"
- "Fuchs/js/intranet/modules/fis.specialties*"
- "Fuchs/js/intranet/fis_main_menu.js"
- "Fuchs/js/intranet/fis_main_go.js"
relatedDecisions:
- "0027-admin-invoice-file-backup-and-clear.md"
---
# Admin "Specialties" page (`/specialties`)
## Summary
An admin-only page that surfaces the otherwise hidden maintenance actions as
cards, each a small form. It exists so support does not need SSMS or a
hand-crafted POST for actions like resetting an invoice's stored PDF. It is
a regular lazily loaded frontend module (`fn = specialties`), gated like the
rest of the Administration menu (`fds_sys > 4`), and reachable both from the
menu (Administration → Spezialfunktionen) and by opening the URL
`/specialties` directly.
The menu entry is deliberately hidden: it only appears when the Administration
dropdown is opened with **Shift** held (Shift+click, or Shift+Enter/Space on
the focused toggle); a plain open hides it again
(`$fis.bindShiftOnlyMenuItems` in `fis_main_menu.js`, driven by the item's
`lclass: 'shift-only'`). This keeps the maintenance actions out of casual
reach without adding another authorization level — the item is still part of
the menu array, so the `/specialties` deep link is unaffected.
## How it works
- **Module load** follows the standard contract (see
`admin-system-status.md`): the menu item `init:specialties` →
`$ocms.init('specialties')` → `POST /do/specialties/auth` → when
`manage > 0`, import `web/fis.specialties.de.js` + `fis.specialties.css`
and call `init2`. `IntranetController.Specialties.cs` answers only that
auth probe (same `GetSystemAdminAuthAsync` gate as the Admin module) and
401s everything else.
- **Deep link.** `IntranetController.Index` renders the same SPA shell for
every `/{fn}` URL and ignores `fn`; there is no client router. `fis_main_go.js`
therefore calls `$fis.openModuleFromPath()` once the admin menu has settled:
it takes the first path segment and calls `$ocms.init(fn)` **only if the
user's menu offers an `init:<fn>` item** (admin dropdown included). A
non-admin opening `/specialties` sees the dashboard, as before; arbitrary
paths never trigger a module load.
- **Cards.** `fis.specialties.js` keeps an array `cards` of builder
functions. Each card is `title + description + <form> + result box`;
`$spc.submit(c, { button, confirm, run, report })` wires HTML5 validation,
a `confirm()`, the `postXT` call (`run(values)` returns the request) and
the inline result (`report(response)` → `{ ok, lines }`). The card calls
the action's **real** endpoint — every such endpoint enforces the admin
gate itself; the page adds no authority.
- **Current cards**
- *Rechnungsdatei zurücksetzen (Soft-Delete)* → `POST
/do/admin/backupclearinvoicefile/<internal Id>` (ADR 0027). Shows the
outcome and the blob backup URI, or the German text for the service's
`reason` (`invoice_not_found`, `not_final`, `external`, `no_file`,
`db_error`). An invoice without a stored file (`hasFile` from
`fds__getInvoice`) is answered `no_file` before any blob copy, so
repeated clicks leave no spare `backup_*` blobs.
- **Adding a card:** append a builder to `cards`, add its texts to
`fis.specialties_txt_de.js`, run `npx gulp min`. No server change is
needed unless the action itself is new.
- **Build:** bundle entries in `Fuchs/bdlconfig.json`
(`intranet:specialties`); generated files under `wwwroot/web/`.
## Key files
- `Fuchs/Controllers/IntranetController.Specialties.cs` — auth probe.
- `Fuchs/Controllers/IntranetController.cs` — `"specialties"` case in `Do`.
- `Fuchs/js/intranet/modules/fis.specialties.js` / `_txt_de.js` / `.scss`.
- `Fuchs/js/intranet/fis_main_menu.js` — menu item + `openModuleFromPath`.
- `Fuchs/js/intranet/fis_main_go.js` — deep link after menu settles.
- `Fuchs/js/intranet/fis_texts_gui_de.js` — `m_specialties` label.
## Related decisions
- [0027 — Admin invoice-file backup-and-clear action](../Decisions/0027-admin-invoice-file-backup-and-clear.md)
+53 -13
View File
@@ -1,6 +1,6 @@
--- ---
status: Active status: Active
lastUpdated: 2026-09-09 lastUpdated: 2026-09-15
applyTo: applyTo:
- "Fuchs/Controllers/IntranetController.Admin.cs" - "Fuchs/Controllers/IntranetController.Admin.cs"
- "Fuchs/Services/SystemStatusService.cs" - "Fuchs/Services/SystemStatusService.cs"
@@ -32,7 +32,10 @@ Access is restricted to users whose `fds_sys` module authorization is greater th
does it push a single "Administration" dropdown entry into `$ocms.ocmsmenu` — containing this does it push a single "Administration" dropdown entry into `$ocms.ocmsmenu` — containing this
module's "Systemüberwachung" (`init:admin`) item alongside `usr`'s "Benutzerverwaltung" module's "Systemüberwachung" (`init:admin`) item alongside `usr`'s "Benutzerverwaltung"
(`init:usr`) item — and re-render `#mainmenu`. Users below the threshold never see the (`init:usr`) item — and re-render `#mainmenu`. Users below the threshold never see the
dropdown and never fetch either module's script. dropdown and never fetch either module's script. Within the dropdown, "Systemüberwachung"
(like "Spezialfunktionen") is a `shift-only` item: it is listed only when the dropdown is
opened with **Shift** held (`$fis.bindShiftOnlyMenuItems`); the `/admin` deep link is
unaffected. This is a convenience/accident guard, not an authorization level.
2. **Server-side gate** — every `/do/admin/*` endpoint resolves 2. **Server-side gate** — every `/do/admin/*` endpoint resolves
`fis_getModuleAuth('fds_sys', authuser)` and returns 401 unless it is > 4. The only `fis_getModuleAuth('fds_sys', authuser)` and returns 401 unless it is > 4. The only
exception is `admin/auth`, which returns `{ manage: 0 }` for unauthorized users so the exception is `admin/auth`, which returns `{ manage: 0 }` for unauthorized users so the
@@ -47,7 +50,7 @@ click "Administration" → $ocms.init('admin')
$ocms.admin.init2() → init3() renders the page $ocms.admin.init2() → init3() renders the page
POST /do/admin/status → { info, probes } full snapshot + all probes POST /do/admin/status → { info, probes } full snapshot + all probes
per-card "Aktualisieren" → POST /do/admin/probe/<component> → { probe } per-card "Aktualisieren" → POST /do/admin/probe/<component> → { probe }
"Test-E-Mail senden" → POST /do/admin/testmail (to/subject/body) → { result } "Test-E-Mail senden" → POST /do/admin/testmail (to/subject/body/attachpdf) → { result }
``` ```
### Backend (`ISystemStatusService` / `SystemStatusService`, scoped) ### Backend (`ISystemStatusService` / `SystemStatusService`, scoped)
@@ -56,21 +59,58 @@ click "Administration" → $ocms.init('admin')
**never the password**), email (mailer enabled/base-url/account/server-id/token-present + **never the password**), email (mailer enabled/base-url/account/server-id/token-present +
OverrideRecipient), blob (enabled/configured/containers), MFR (host/creds-present/sync), OverrideRecipient), blob (enabled/configured/containers), MFR (host/creds-present/sync),
Key Vault (vault-uri/app-prefix/managed-secret-count/client-registered). Key Vault (vault-uri/app-prefix/managed-secret-count/client-registered).
- Probes (`database`, `keyvault`, `blob`, `mfr`) each return a `SystemProbeResult` - Probes (`database`, `email`, `keyvault`, `blob`, `mfr`) each return a `SystemProbeResult`
(`status` = `ok`/`error`/`disabled`/`unconfigured`, `ok`, `message`, `detail`, `durationMs`, (`status` = `ok`/`error`/`disabled`/`unconfigured`, `ok`, `message`, `detail`, `durationMs`,
optional `metrics`). They never throw — failures are captured in the result. `database` runs optional `metrics`). They never throw — failures are captured in the result. `database` runs
`SELECT @@SERVERNAME, DB_NAME(), SUSER_SNAME()`; `keyvault` reads the first managed secret via `SELECT @@SERVERNAME, DB_NAME(), SUSER_SNAME()`; `email` calls
the DI `SecretClient`; `blob` calls `IBlobStorageService.CheckConnectivityAsync` (account-info `IComService.VerifyCredentialsAsync` (GET `{BaseUrl}/api/mailer/verify` with the configured
request **plus a per-container blob count** for the invoice/reminder containers, surfaced as Basic-auth account/token — see the Mailer API contract) and only turns `ok` when the Mailer API
`metrics` and totalled in the message); `mfr` calls `IMfrClientFactory.Create().GetEntities()`. itself confirms the credentials (`ok:true`); when `ProcessWebComSettings.ServerId` is configured
The generic `metrics` (label/value pairs) is how a probe reports extra facts for display — the it is sent along as `&serverid=...` so the API also confirms this server belongs to the account
blob probe uses it for the file counts. (an account/token that is valid but for a server the account doesn't own comes back `ok:false`
- `SendTestEmailAsync(to, subject, body)` HTML-encodes the body and sends via `IComService`, so with the mismatched `serverid` echoed, surfaced in `detail`); a configured-but-wrong token
the `Fuchs:Email:OverrideRecipient` redirect applies exactly as for any other mail; the result surfaces as `error`, never as a false-positive `ok` — the `mailerEnabled`/`tokenConfigured` flags
reports the requested recipient and, when active, the override target. in `GetInfo()` describe configuration only and were never proof the credentials actually work;
`keyvault` reads
the first managed secret via the DI `SecretClient`; `blob` calls
`IBlobStorageService.CheckConnectivityAsync` (account-info request **plus a per-container blob
count** for the invoice/reminder containers, surfaced as `metrics` and totalled in the message);
`mfr` calls `IMfrClientFactory.Create().GetEntities()`. The generic `metrics` (label/value pairs)
is how a probe reports extra facts for display — the blob probe uses it for the file counts.
- `SendTestEmailAsync(to, subject, body, attachPdf)` HTML-encodes the body and sends via
`IComService`, so the `Fuchs:Email:OverrideRecipient` redirect applies exactly as for any other
mail; the result reports the requested recipient and, when active, the override target.
With `attachPdf` (dialog select "PDF-Anhang mitsenden", default *Ja*; absent form value ⇒ on)
a one-page letter PDF (`Fuchs-Intranet-Testmail.pdf`) is rendered in memory via
`IPdfService.WriteLetterAsync` + `DocToPdfBytes` — standard Fuchs letterhead, the recipient in
the address window, `Datum`/`Umgebung`/`Nummer=admin_test` in the admin block, the mail's own
subject and body as the letter text; no DB or stored document is touched. This exercises the
Mailer's multipart/attachment path, so per ADR 0028 `sent` is only `true` when the Mailer also
confirmed the attachment (`attachmentsProcessed == 1`); the result's `attachmentName` says
whether an attachment was part of the send. A PDF rendering failure is reported in the result
(`sent=false`, message names the error) and nothing is sent.
- Every probe emits the `fuchs.systemstatus.probes` counter tagged by component + status and an - Every probe emits the `fuchs.systemstatus.probes` counter tagged by component + status and an
`systemstatus.probe` activity span. `systemstatus.probe` activity span.
### Cards on the page
The page renders one non-probe system card, five refreshable connectivity cards (in this
order), and the non-refreshable startup-checks card. UI labels are from `fis.admin_txt_de.js`.
| Card (label) | Shows | Data source |
| --- | --- | --- |
| System | Hostname, OS, .NET framework, Environment, Test-Deployment flag, Prozess-ID, Laufzeit/Start (UTC) | `GetInfo()` — `Environment`/`Process.GetCurrentProcess()`, no network call |
| SQL-Datenbank (`database`) | Server, Datenbank, Login (never the password) | Config: `fuchs_fds_ConnectionString` parsed. Probe: `SELECT @@SERVERNAME, DB_NAME(), SUSER_SNAME()` |
| E-Mail-Dienst (`email`) | Mailer aktiv, Base-URL, Account, Server-ID, Token hinterlegt, OverrideRecipient/Umleitung aktiv | Config: `ProcessWebComSettings` / `FuchsEmailSettings`. Probe: `IComService.VerifyCredentialsAsync` → `GET {BaseUrl}/api/mailer/verify` at ProcessWeb, `ok` only when the Mailer API confirms account (+ server-id when configured) |
| Azure Blob Storage (`blob`) | Aktiviert, Konfiguriert, Rechnungs-/Mahnungs-Container, per-container file counts (Metrics) | Config: `AzureBlobStorageSettings` / `AzureBlobStorage_ConnectionString`. Probe: `IBlobStorageService.CheckConnectivityAsync` (account-info + blob count per container) |
| MFR (ERP) (`mfr`) | Host, Zugangsdaten hinterlegt, Sync aktiv | Config: `Fds:MFR_host` / `Fds:MFR_UserName` / `Fds:SyncEnabled`. Probe: `IMfrClientFactory.Create().GetEntities()` |
| Azure Key Vault (`keyvault`) | Vault-URI, App-Präfix, Verwaltete Secrets (count), Client registriert | Config: `SecretManagement:VaultUri`/`AppName`/`ManagedSecretKeys`, whether a `SecretClient` is DI-registered. Probe: reads the first key in `ManagedSecretKeys` via `SecretClient.GetSecretAsync` |
| Startup-Prüfungen (non-refreshable) | OK/Fehler/übersprungen per check: Key Vault, SQL-Datenbank, MFR, Spire.PDF-Lizenz, E-Mail (Startup-Probe) | `StartupCheckReporter.Latest`, captured once by `StartupSelfTestService` at boot; see next section |
Each connectivity card also carries a colored status pill (`ok`/`error`/`disabled`/`unconfigured`)
and a per-card "Aktualisieren" button (`POST /do/admin/probe/<component>`); the topbar "Alle
prüfen" button re-runs all five in parallel via `POST /do/admin/status`. The E-Mail-Dienst card
additionally hosts the test-email dialog (see `SendTestEmailAsync` below).
### Startup-checks widget (non-refreshable) ### Startup-checks widget (non-refreshable)
`StartupSelfTestService` runs the boot self-test once (Key Vault / Database / MFR / PDF-license / `StartupSelfTestService` runs the boot self-test once (Key Vault / Database / MFR / PDF-license /
mailer, gated by `Fuchs:StartupChecks:*`). It now also writes its outcome to the singleton mailer, gated by `Fuchs:StartupChecks:*`). It now also writes its outcome to the singleton
+56 -22
View File
@@ -1,6 +1,6 @@
--- ---
status: Active status: Active
lastUpdated: 2026-09-09 lastUpdated: 2026-09-16
applyTo: applyTo:
- "Fuchs/Controllers/IntranetController.AdminUsers.cs" - "Fuchs/Controllers/IntranetController.AdminUsers.cs"
- "Fuchs/js/intranet/modules/fis.usr*.js" - "Fuchs/js/intranet/modules/fis.usr*.js"
@@ -11,20 +11,23 @@ applyTo:
relatedDecisions: relatedDecisions:
- "0014-admin-user-management.md" - "0014-admin-user-management.md"
- "0015-split-admin-user-management-module.md" - "0015-split-admin-user-management-module.md"
- "0020-user-management-create-and-delete.md"
- "0021-user-group-management.md"
--- ---
# User Management module (`usr`) # User Management module (`usr`)
## Summary ## Summary
The **Benutzerverwaltung** module lets a privileged operator see every non-deleted intranet The **Benutzerverwaltung** module lets a privileged operator see every non-deleted intranet
user account (name + email + active/disabled), reset a password, change an email address, and user account (name + email + active/disabled), create a new account, delete an existing one,
view/change group assignments. It shares its sidemenu-visibility and server-side authorization reset a password, change an email address, and view/change group assignments. It shares its
gate with the separate Admin/status-dashboard module (`fds_sys > 4`) but is otherwise fully sidemenu-visibility and server-side authorization gate with the separate Admin/status-dashboard
independent — own `fn=usr`, own frontend bundle, own controller partial. See module (`fds_sys > 4`) but is otherwise fully independent — own `fn=usr`, own frontend bundle,
[0015](../Decisions/0015-split-admin-user-management-module.md) for why these were split from own controller partial. See [0015](../Decisions/0015-split-admin-user-management-module.md) for
one module into two, and [0014](../Decisions/0014-admin-user-management.md) for the original why these were split from one module into two, [0014](../Decisions/0014-admin-user-management.md)
decisions this one carries forward (password always emailed, dual auth axis, reuse of for the original decisions this one carries forward (password always emailed, dual auth axis,
`fis_admin_setUserAccount` for email changes). reuse of `fis_admin_setUserAccount` for email changes), and
[0020](../Decisions/0020-user-management-create-and-delete.md) for create/delete.
## How it works ## How it works
@@ -49,17 +52,25 @@ click "Benutzerverwaltung" → $ocms.init('usr')
POST /do/usr/auth → { manage, level } (manage>0 ⇒ authorized) POST /do/usr/auth → { manage, level } (manage>0 ⇒ authorized)
load /web/fis.usr.de.js + /web/fis.usr.css load /web/fis.usr.de.js + /web/fis.usr.css
$ocms.usr.init2() → init3() → renderLst() $ocms.usr.init2() → init3() → renderLst()
POST /do/usr/list → { users: [...] } grid of every non-deleted account POST /do/usr/list → { users: [...] } grid of every non-deleted account (incl. `groups` csv)
row click → per-row ribbon (iMn): Passwort zurücksetzen / E-Mail ändern / Gruppenzugehörigkeit POST /do/usr/list {useraccount_id} → { users: [one] } single-row refresh (refreshRow)
base ribbon (eM) → "Neuer Benutzer" (always available, not just when a row is selected)
POST /do/usr/create {name, firstname, email, title?, gender?, mobile?} → { ok, useraccount_id, emailed } | 409 duplicate_email
row click → per-row ribbon (iMn): Passwort zurücksetzen / E-Mail ändern / Gruppenzugehörigkeit / Benutzer löschen
POST /do/usr/pwdreset {useraccount_id, newpassword?} → { ok, emailed } POST /do/usr/pwdreset {useraccount_id, newpassword?} → { ok, emailed }
POST /do/usr/email {useraccount_id, email} → { ok } | 409 duplicate_email POST /do/usr/email {useraccount_id, email} → { ok } | 409 duplicate_email
POST /do/usr/groups {useraccount_id} → { groups: [...] } POST /do/usr/groups {useraccount_id} → { groups: [...] }
POST /do/usr/groupset {useraccount_id, usergroup_id, member} → { ok, member } POST /do/usr/groupset {useraccount_id, usergroup_id, member} → { ok, member }
POST /do/usr/delete {useraccount_id} → { ok } | 409 cannot_delete_self
``` ```
### Backend (`IntranetController.AdminUsers.cs`) ### Backend (`IntranetController.AdminUsers.cs`)
- `list` → `fis_admin_getUserAccountList(@authaccount, NULL, NULL, NULL)`, all non-deleted - `list` → `fis_admin_getUserAccountList(@authaccount, @useraccount_id, NULL, NULL)`, all non-deleted
accounts with decrypted name/email/disabled flag. accounts with decrypted name/email/disabled flag plus a `groups` column — a `', '`-separated,
alphabetically ordered csv of the account's active group memberships (`STRING_AGG` over
decrypted `fis_usergroups.name_enc`, deleted groups/memberships excluded). An optional
`useraccount_id` form field narrows the result to one account so the frontend can refresh a
single row without re-rendering the grid.
- `pwdreset` → the admin may type a specific password or leave it blank; validated with a plain - `pwdreset` → the admin may type a specific password or leave it blank; validated with a plain
`ValidatePassword(minLength: 6, numUpper: 0, numLower: 0, numNumbers: 0, numSpecial: 0)` — no `ValidatePassword(minLength: 6, numUpper: 0, numLower: 0, numNumbers: 0, numSpecial: 0)` — no
hidden composition requirements, matching what the UI actually tells the admin (a prior bug hidden composition requirements, matching what the UI actually tells the admin (a prior bug
@@ -77,29 +88,52 @@ click "Benutzerverwaltung" → $ocms.init('usr')
changed (`@password = NULL`, so the `WHEN MATCHED` update path leaves `password_enc` alone). A changed (`@password = NULL`, so the `WHEN MATCHED` update path leaves `password_enc` alone). A
duplicate email (`THROW 60001` in the proc) surfaces as HTTP 409 `{ error: "duplicate_email" }`. duplicate email (`THROW 60001` in the proc) surfaces as HTTP 409 `{ error: "duplicate_email" }`.
- `groups` → the new `fis_admin_getUserGroupMembership(@authaccount, @useraccount_id)` function - `groups` → the new `fis_admin_getUserGroupMembership(@authaccount, @useraccount_id)` function
returns every group plus a `member` bit for that one user. returns every group plus a `member` bit for that one user. The group catalog itself (create/
rename/delete) is managed in the separate `usrgrp` module — see
[admin-group-management.md](admin-group-management.md).
- `groupset` → calls `fis_admin_addGroupMember`/`remGroupMember` depending on the posted - `groupset` → calls `fis_admin_addGroupMember`/`remGroupMember` depending on the posted
`member` flag — applied immediately per checkbox toggle, no separate save step. `member` flag — applied immediately per checkbox toggle, no separate save step.
- `create` → `fis_admin_createUserAccount(@authaccount, @name, @firstname, @title, @gender,
@email, @mobile, @password, @enc_key)`. Only name/firstname/email are required from the
dialog; the admin may leave password blank (the proc auto-generates one and returns it as
`newpassword`, mirroring `fis_admin_resetUserPassword`'s pattern). The controller then emails
the new account's credentials via `BuildWelcomeEmail` — same "emailed, never shown on screen"
rule as `pwdreset` — and returns `emailed` so the UI can tell the admin whether delivery
succeeded. Duplicate email → 409 `duplicate_email`, same mapping as `email`.
- `delete` → `fis_admin_delUserAccount(@authaccount, @useraccount_id)` (soft-delete: sets
`UserDeleted`/`DateDeleted`). The controller rejects a self-delete with 409
`cannot_delete_self` before even calling SQL; the proc enforces the same guard independently.
See [0020](../Decisions/0020-user-management-create-and-delete.md) for why both procs needed
the `intranet_admin` auth gate added when they were wired up.
### Frontend (`fis.usr.js` + `fis.usr_txt_de.js` + `fis.usr.scss`) ### Frontend (`fis.usr.js` + `fis.usr_txt_de.js` + `fis.usr.scss`)
A self-contained list+ribbon module modeled on Banking (`fis.bam.js`)'s `eM`/`iMn` pattern A self-contained list+ribbon module modeled on Banking (`fis.bam.js`)'s `eM`/`iMn` pattern
rather than the Admin dashboard's card layout: `renderLst` builds the table via rather than the Admin dashboard's card layout: `renderLst` builds a card-style row grid (avatar
`$fis.frm_list`/`$$.tblset`; a row click toggles `.selected` and swaps the `#topbar` ribbon initials badge, name/email/groups columns, a status pill) rather than a plain `<table>`, with
between the base "Neu Laden" button (`eM`) and the three per-row actions (`iMn`). Each action is each row produced by `buildRow(rw)`. Changes that only affect one account (`emailDlg`, every
a dialog (`pwdResetDlg`/`emailDlg`/`groupsDlg`) using `$ocms.dlgform`/`$ocms.dlg` with a manual checkbox toggle in `groupsDlg`) call `refreshRow(rw)`, which re-posts `usr/list` with that
`useraccount_id` and swaps the row in place — selection and the per-row ribbon survive, and the
groups csv is always current. Create/delete/password-reset still do a full `renderLst`. A row click
toggles `.selected` and swaps the `#topbar` ribbon between the base ribbon (`eM`: "Neuer
Benutzer" always available, plus "Neu Laden" once a row was selected) and the four per-row
actions (`iMn`: Passwort zurücksetzen / E-Mail ändern / Gruppenzugehörigkeit / Benutzer löschen).
`pwdResetDlg`/`emailDlg`/`groupsDlg`/`createDlg` use `$ocms.dlgform`/`$ocms.dlg` with a manual
`submit`/`postXT` (not the built-in `success`-URL wiring) so a 409's `error` code can be mapped `submit`/`postXT` (not the built-in `success`-URL wiring) so a 409's `error` code can be mapped
to a specific German message via `$uct.err`. to a specific German message via `$uct.err`; `deleteUser` instead follows Banking's
`confirm(...)`-before-destructive-action pattern (`fis.bam.js`'s `smd`/`mbd`) since there's
nothing to collect beyond the confirmation itself.
## Key files ## Key files
- `Fuchs/Controllers/IntranetController.AdminUsers.cs` — `Do_Process_AdminUsers` dispatch + `BuildPasswordResetEmail` + `NotifyAdminUserActionFailedAsync`. - `Fuchs/Controllers/IntranetController.AdminUsers.cs` — `Do_Process_AdminUsers` dispatch + `BuildPasswordResetEmail`/`BuildWelcomeEmail` + `NotifyAdminUserActionFailedAsync`.
- `Fuchs/Controllers/IntranetController.Admin.cs` — shared `AdminMinAuthExclusive`/`GetSystemAdminAuthAsync`/`AdminJson` this module reuses. - `Fuchs/Controllers/IntranetController.Admin.cs` — shared `AdminMinAuthExclusive`/`GetSystemAdminAuthAsync`/`AdminJson` this module reuses.
- `Fuchs/js/intranet/modules/fis.usr.js`, `fis.usr_txt_de.js`, `fis.usr.scss` — the module, texts, styles (bundled via `bdlconfig.json`, `intranet:usr` context). - `Fuchs/js/intranet/modules/fis.usr.js`, `fis.usr_txt_de.js`, `fis.usr.scss` — the module, texts, styles (bundled via `bdlconfig.json`, `intranet:usr` context).
- `Fuchs/js/intranet/fis_main_menu.js` (`addAdminMenuButtonsIfAuthorized`) + `fis_main_go.js` — conditional menu buttons (this module and `admin`). - `Fuchs/js/intranet/fis_main_menu.js` (`addAdminMenuButtonsIfAuthorized`) + `fis_main_go.js` — conditional menu buttons (this module and `admin`).
- `Fuchs_Database/dbo/Functions/fis_admin_getUserAccountList.sql`, `fis_admin_getUserGroupList.sql`, `fis_admin_getUserGroupMembership.sql` — read-side user/group queries. - `Fuchs_Database/dbo/Functions/fis_admin_getUserAccountList.sql`, `fis_admin_getUserGroupList.sql`, `fis_admin_getUserGroupMembership.sql` — read-side user/group queries.
- `Fuchs_Database/dbo/Stored Procedures/fis_admin_resetUserPassword.sql`, `fis_admin_setUserAccount.sql`, `fis_admin_addGroupMember.sql`, `fis_admin_remGroupMember.sql` — write-side user/group procs. - `Fuchs_Database/dbo/Stored Procedures/fis_admin_resetUserPassword.sql`, `fis_admin_setUserAccount.sql`, `fis_admin_addGroupMember.sql`, `fis_admin_remGroupMember.sql`, `fis_admin_createUserAccount.sql`, `fis_admin_delUserAccount.sql` — write-side user/group procs.
- `Fuchs.Tests/AdminUserManagementTests.cs` — password-reset email content tests. - `Fuchs.Tests/AdminUserManagementTests.cs` — password-reset and welcome-email content tests.
## Related decisions ## Related decisions
- [0014 — Admin user management (original decisions, still in force)](../Decisions/0014-admin-user-management.md) - [0014 — Admin user management (original decisions, still in force)](../Decisions/0014-admin-user-management.md)
- [0020 — Wire up create/delete, welcome email on create, guard delete](../Decisions/0020-user-management-create-and-delete.md)
- [0015 — Split Admin into separate Status/User-Management modules](../Decisions/0015-split-admin-user-management-module.md) - [0015 — Split Admin into separate Status/User-Management modules](../Decisions/0015-split-admin-user-management-module.md)
- [0003 — Interrupting exceptions notify the user](../Decisions/0003-interrupting-exceptions-notify-the-user.md) - [0003 — Interrupting exceptions notify the user](../Decisions/0003-interrupting-exceptions-notify-the-user.md)
@@ -0,0 +1,99 @@
---
status: Active
lastUpdated: 2026-09-16
applyTo:
- "Fuchs/Program.cs"
- "Fuchs/Logging/**"
- "Fuchs/code/FuchsIntranet.cs"
- "Fuchs/Controllers/**"
- "Fuchs/Observability/FuchsTelemetry.cs"
relatedDecisions:
- "0003-structured-diagnostic-logging.md"
- "0003-interrupting-exceptions-notify-the-user.md"
- "0004-opentelemetry-observability.md"
- "0023-user-action-failures-persisted-to-db-log.md"
- "0024-multilogger-sql-log-sink.md"
---
# Error logging pipeline
## Summary
A `_logger.LogWarning`/`-Error`/`-Critical` call anywhere in the app — or in OCORE code called
from it — reaches three places automatically: the always-on file sink, a DB row in
`[dbo].[fds__debuglog]` (what support actually queries), and, for a user-initiated action that
threw unhandled, a SignalR toast to the user. One `ILogger` call is the single thing a call site
writes; nothing calls a stored procedure directly for attribution logging.
## How it works
### The two `ILoggerProvider`s
- **`FuchsLoggerProvider`** (`Fuchs/Logging/FuchsLoggerProvider.cs`, [0003](0003-structured-diagnostic-logging.md)):
every level ≥ Debug → `Debug.WriteLine` + a rolling-free text file (`logs/AppLog.txt` /
`logs/ErrorLog.txt`). Always on, no DB dependency.
- **`OCORE.MultiLogger.SqlLoggerProvider`** ([0024](0024-multilogger-sql-log-sink.md)): registered
in `Program.cs` via `builder.Logging.AddSqlLogger()`, gated by
`Fuchs:Logging:SqlSink:Enabled` (default on), filtered to `LogLevel.Warning`+. Writes via
`EXECUTE [dbo].[fds__admin_logdebug_ext]` into `[dbo].[fds__debuglog]` — the same table the
legacy, now-unused `fds__admin_logdebug` proc wrote to.
### Attribution: EventId, category, scope
- **`CodeReference`** (the row's short label) is `COALESCE(EventId.Name, ILogger category,
'MultiLogger')`, computed in the stored procedure. Give a log call an explicit `EventId` when
the call site should be individually greppable:
```csharp
_logger.LogError(new EventId(0, "SQL Error in getSQLDataSet"), ex, "SQL error in {Procedure}: ...", ...);
```
See `FIS_SQLOptions.OnError`, `IntranetController.Do`'s catch-all, `PdfPreviewAsync`, and the
`bam/up` (banking upload) handlers for the pattern — each preserves the exact string the old,
explicit `debug_log()` calls used, so existing saved queries over `CodeReference` still match.
- **`RequestLogScopeMiddleware`** (`Fuchs/Logging/RequestLogScopeMiddleware.cs`, registered after
`app.UseAuthentication()`) pushes `{requestId, path, authuser}` as an `ILogger` scope for the
whole request. Every log line written while handling it — including ones written deep inside
OCORE's SQL helpers — carries that automatically, without the call site doing anything.
`authuser` specifically lands in the dedicated `fds__debuglog.AuthUser` column (OCORE's
`MultiLogger_base.Log` picks the `"authuser"` scope key out into `LogEntry.AuthUser`); the rest
of the scope + any `{Placeholder}` args + an explicit `data:` object are merged into one redacted
JSON blob (`fds__debuglog.Data`).
- **No request/service-scoped logger available** (`new FIS_SQLOptions()` — ~15 call sites in
`Services/*.cs` construct it this way): `FIS_SQLOptions.OnError` falls back to the static
`OCORE.Logging.Logger` (set once in `Program.cs` to `app.Logger`), so those SQL errors are still
attributed, just under a less specific category.
### Placeholder naming
Always `{Foo}`, never `{@Foo}`. Plain `Microsoft.Extensions.Logging` (not Serilog) does not treat
`@` as a destructuring operator — it stays a literal leading character of the resulting property
name (`"@Foo"`), which is confusing in the serialized `Data` column.
### Redaction and cost
`OCORE.MultiLogger.LogRedaction.SerializeRedacted` masks property names matching known-sensitive
fragments/segments (`password`, `secret`, `token`, `pin`, `otp` as a whole identifier segment —
not substrings like `Mapping`/`HotPath`) at any nesting depth, degrades one unserializable context
entry to its `ToString()` instead of losing the whole payload, and tolerates self-referencing
graphs. `LogEntry.dataSerialized` is computed lazily — at most once, only if a sink actually reads
it — so a Warning+ call that no sink is currently listening for costs nothing extra to serialize.
### User-visible failure surface (unrelated to the DB write, [0023](0023-user-action-failures-persisted-to-db-log.md))
- `IntranetController.Do`'s catch-all publishes a SignalR toast (`IEventService.UserIssueAsync`)
for any unmodeled exception, for authenticated users only.
- A 500 response carries its message in the `x-ocms-desc` header (`ServerError(msg)` →
`EncodeResponseDescription`), which the frontend's default failure handler
(`$ocms.failure`/`oci_basic.js`) reads.
- PDF-preview endpoints go through the shared `PdfPreviewAsync` guard: logs, counts
`fuchs.pdf.previews{what,outcome}` (`success`/`gone`/`failure` — an expired-draft `410` is its
own `gone` outcome, not a render failure), toasts an authenticated caller, and returns a
message with the innermost exception text (omitted for the handful of preview endpoints
reachable without authentication — see `_allowedGet` in `IntranetController.cs`).
## Configuration
- `Fuchs:Logging:SqlSink:Enabled` (bool, default `true`) — off switch for the SQL sink without a
redeploy.
- SQL sink connects via `fuchs_fds_ConnectionString` (same connection as everything else), proc
`dbo.fds__admin_logdebug_ext`, `TruncateTextAt = 4000`.
## Extending this
- Adding a new sink (email/SMS on Critical, a rolling file replacing `FuchsLoggerProvider`) is a
registration in `Program.cs` — `OCORE.MultiLogger` already ships `EmailLoggerOptions`/
`SMSLoggerOptions`/`FileLoggerProvider`.
- A new place that needs DB attribution just calls `_logger.LogWarning`/`-Error` with an
`EventId` if it wants a specific `CodeReference` — never add a second, explicit stored-procedure
call next to it; that duplicates the row once the SQL sink picks the `ILogger` call up.
+24 -6
View File
@@ -1,6 +1,6 @@
--- ---
status: Active status: Active
lastUpdated: 2026-09-09 lastUpdated: 2026-10-01
applyTo: applyTo:
- "Fuchs/Services/InvoiceDraft*" - "Fuchs/Services/InvoiceDraft*"
- "Fuchs/Services/IInvoiceDraft*" - "Fuchs/Services/IInvoiceDraft*"
@@ -19,8 +19,8 @@ applyTo:
relatedDecisions: relatedDecisions:
- "0006-backend-authoritative-draft-editing.md" - "0006-backend-authoritative-draft-editing.md"
- "0007-targeted-draft-signalr-groups.md" - "0007-targeted-draft-signalr-groups.md"
- "0009-block-setprice-modes-are-irreversible-mutations.md"
- "0016-user-defined-set-structure.md" - "0016-user-defined-set-structure.md"
- "0019-set-price-modes-are-keyed-on-the-set.md"
--- ---
# Live draft editing (backend-authoritative invoice previews) # Live draft editing (backend-authoritative invoice previews)
@@ -99,6 +99,21 @@ Expiry: Server (timer) --SignalR draftExpiring{token,secondsLeft}--> warn "bit
(`RecomputeLevels`), rendered as an indent class in the editor and a per-level indent in the (`RecomputeLevels`), rendered as an indent class in the editor and a per-level indent in the
PDF. `item.setprice` sums a header's **transitive** descendants. Structure deltas never PDF. `item.setprice` sums a header's **transitive** descendants. Structure deltas never
change values — only order, membership, levels and therefore position numbers. change values — only order, membership, levels and therefore position numbers.
- **Concurrency.** The editor fires patches, state fetches, previews and saves for one token
concurrently, and the session payload (`JObject`/`JArray`, the history list) is not thread-safe.
Every read and write of the payload runs under the session's `SyncRoot`, and everything the
controller serialises afterwards (`BuildState`, `GetHistory`, the `FdsInvoiceData` a preview or
flush uses) is a deep-cloned snapshot taken under that lock — never a live reference. Flushes are
additionally serialised per session on `FlushGate` (a `SemaphoreSlim`, awaited — `SyncRoot`
cannot be held across the registration's `await`): the create/update decision in
`FlushToDbAsync` is read-then-write across that await, so two overlapping saves of a
not-yet-registered draft (double-click on *Zwischenspeichern*, or *Zwischenspeichern* followed
by preview → confirm) would otherwise both call `fds__createInvoice` and leave an orphaned
invoice. The waiting save snapshots **after** it acquires the gate, so it persists the newer
state as an update of the row the first save created. `FlushToDbAsync` reports that decision
(`DraftFlushResult.Updated`) and the controller publishes `InvoiceDraftCreated`/`-Updated` from
it rather than from the session state it saw before the flush. Patches are never blocked by a
running save. `DraftSessionConcurrencyTests` pins both the lock and the gate.
- **Cache** (`InvoiceDraftCache`, singleton) stores sessions by token with an idle sliding - **Cache** (`InvoiceDraftCache`, singleton) stores sessions by token with an idle sliding
TTL; `InvoiceDraftExpiryService` (a `BackgroundService`) warns before, and evicts after, TTL; `InvoiceDraftExpiryService` (a `BackgroundService`) warns before, and evicts after,
the TTL. TTL/warn-lead are configurable under `Fuchs:DraftEditing`. the TTL. TTL/warn-lead are configurable under `Fuchs:DraftEditing`.
@@ -146,9 +161,12 @@ amount pair.
- **Orchestration** (`ReminderDraftEditService`, scoped) flushes to the DB by reusing - **Orchestration** (`ReminderDraftEditService`, scoped) flushes to the DB by reusing
`IReminderService.RegisterReminderAsync`, and renders previews from a synthesised `IReminderService.RegisterReminderAsync`, and renders previews from a synthesised
`ReminderRegistration` (including the single-invoice `invoices` row the reminder PDF table `ReminderRegistration` (including the single-invoice `invoices` row the reminder PDF table
renders) so a preview needs no DB round-trip. **Note:** `RegisterReminderAsync` is create-only renders) so a preview needs no DB round-trip. A first save runs `fds__createReminder`; every
(there is no `fds__setReminder` update proc), so a re-saved reminder draft does not update the later save of the same session runs `fds__setReminder`, which updates the row in place (version
prior DB row — the primary flow (preview → confirm) flushes once immediately before finalising. bump, same result shape) and refuses — `@Id` NULL → the batch throws → "nicht gespeichert" — once
the reminder is finalised or has a stored file. Flushes are serialised on the session's
`FlushGate` exactly like invoice flushes (see *Concurrency* above), so overlapping saves cannot
create two reminder rows either.
- **Cache/expiry** (`ReminderDraftCache` singleton + `ReminderDraftExpiryService`) mirror the - **Cache/expiry** (`ReminderDraftCache` singleton + `ReminderDraftExpiryService`) mirror the
invoice ones and share the same `Fuchs:DraftEditing` TTL config. invoice ones and share the same `Fuchs:DraftEditing` TTL config.
- **Signals** reuse the shared `DraftPreviewHub` + `IDraftNotifier` unchanged — the token-keyed - **Signals** reuse the shared `DraftPreviewHub` + `IDraftNotifier` unchanged — the token-keyed
@@ -170,5 +188,5 @@ amount pair.
## Related decisions ## Related decisions
- [0006 — Backend-authoritative draft editing](../Decisions/0006-backend-authoritative-draft-editing.md) - [0006 — Backend-authoritative draft editing](../Decisions/0006-backend-authoritative-draft-editing.md)
- [0007 — Targeted draft SignalR groups](../Decisions/0007-targeted-draft-signalr-groups.md) - [0007 — Targeted draft SignalR groups](../Decisions/0007-targeted-draft-signalr-groups.md)
- [0009 — Block set-price modes are irreversible mutations](../Decisions/0009-block-setprice-modes-are-irreversible-mutations.md)
- [0016 — User-defined set structure](../Decisions/0016-user-defined-set-structure.md) - [0016 — User-defined set structure](../Decisions/0016-user-defined-set-structure.md)
- [0019 — Set-price modes are keyed on the set](../Decisions/0019-set-price-modes-are-keyed-on-the-set.md) (supersedes 0009)
@@ -0,0 +1,130 @@
---
status: Active
lastUpdated: 2026-09-17
applyTo:
- "Fuchs/Services/IComService.cs"
- "Fuchs/Services/ProcessWebComService.cs"
- "Fuchs/Services/ProcessWebComSettings.cs"
- "Fuchs/Services/FuchsMailerClient.cs"
- "Fuchs/Services/MailerAttachmentPolicy.cs"
- "Fuchs.Tests/ProcessWebComServiceTests.cs"
- "Fuchs.Tests/FuchsMailerClientTests.cs"
relatedDecisions:
- "0028-mailer-push-com-multipart-and-attachment-confirmation.md"
- "0029-outbound-mail-via-ocore-pwmailer-client.md"
---
# ProcessWeb Mailer interface (outbound email/SMS)
## Summary
All outbound email and SMS leaves Fuchs through `IComService` →
`ProcessWebComService`, which is a thin adapter over the OCORE_web
**`IPWMailerClientService`** (`OCORE_web/comm/pwmailer/`). The client owns
the HTTP contract of the ProcessWeb Mailer API; Fuchs owns what is specific
to Fuchs — the recipient-override safety net, the signature, the attachment
limits, the `fds__emaillog` audit row and telemetry.
The authoritative, server-side contract is the Mailer's own function catalog:
> **https://processweb.de/api/mailer/fc** — subject to change as features are
> added, but kept backward compatible. The OCORE_web client is kept in sync
> with it (its header says so and names the server-side files); read both
> before changing anything here.
## How it works
### Layering
| Layer | File | Responsibility |
|-------|------|----------------|
| Adapter | `Fuchs/Services/ProcessWebComService.cs` | `IComService`: validate recipient, apply `Fuchs:Email:OverrideRecipient`, append signature, build a `PWMailerMessage`, interpret the `PWMailerResult`, write `fds__emaillog`, emit metrics |
| Fuchs client | `Fuchs/Services/FuchsMailerClient.cs` | `PWMailerClientService` subclass; overrides `ValidateMessage` to add the attachment limits (`MailerAttachmentPolicy`) on top of the contract's base rules |
| OCORE client | `OCORE_web/comm/pwmailer/PWMailerClientService*.cs` | endpoints, envelope, JSON vs multipart (unnamed envelope field + one file part per attachment), `attachmentCount`, Basic auth, token resolution, `x-pcw-excd` error headers, retries/timeouts, typed results |
### Configuration
`Fuchs:Mailer` (`ProcessWebComSettings`) stays the single config section. In
`Program.cs` it is mapped onto `PWMailerClientOptions`:
- `BaseUrl` (`https://processweb.de`) + `/api/mailer` → `PWMailerClientOptions.BaseUrl`
- `AccountId` → `Account`, `ServerId` → `ServerId`, `Token` → `Token`
- `TokenSecretName = "Fuchs--Mailer--Token"` — the same Key Vault secret
OCORE secret management already maps into `Fuchs:Mailer:Token`. In
production the configuration value is therefore already the plain token;
the name only matters when the `MANAGED_BY_KEYVAULT` placeholder is still
in place (e.g. locally), where the client loads it on demand.
- `Attachments` (`MaxFileBytes` 10 MB, `MaxMessageBytes` 20 MB, `MaxCount`
10 — the contract defaults) feed `FuchsMailerClient`'s pre-validation.
Registration: `AddPWMailerClient<FuchsMailerClient>()` (singleton, named
`HttpClient` `PWMailerClient`). `ProcessWebComService` stays scoped.
### Sending an email
1. `SendEmailAsync` validates the address, applies the override, appends the
signature, and builds `PWMailerMessage { To, Cc, Subject, Body, Reference,
ComType = "e", Attachments }`. `AttachmentCount` is left `null`, so the
client sends the real count and the Mailer cross-checks it (400 on
mismatch).
2. `FuchsMailerClient.ValidateMessage` runs *inside* `PushAsync` before any
bytes go out: base rules (server id, recipient, subject, body, empty file,
missing extension) plus Fuchs limits (count, per-file size, total size,
forbidden extensions — `MailerAttachmentPolicy.ForbiddenExtensions` is the
contract's quoted subset; the server's list is longer and authoritative).
A violation returns a result with `StatusCode 0`, `Attempts 0` and the
`"; "`-joined rule messages as `Error`.
3. The client posts JSON (no attachments) or multipart (with) to
`POST /api/mailer/push_com`, never retrying a push.
4. The adapter interprets the `PWMailerResult<PWMailerPushReceipt>`:
| Result | `SendEmailAsync` | audit `fds__emaillog` | `fuchs.emails.failed{reason}` |
|--------|------------------|-----------------------|-------------------------------|
| `Success` and (no attachments or `AttachmentsProcessed == sent`) | `true` | `guid = comStackID` | — (`fuchs.emails.sent`) |
| `Success` but `AttachmentsProcessed != sent` | `false` | counts in log, `guid = comStackID` | `attachments-mismatch` |
| `IsConflict` (409: identical message still pending at the Mailer — it will be sent, nothing new queued) | `true` | "Duplicate of a still-pending entry (409) …" | — (`fuchs.emails.sent`) |
| `StatusCode 0`, `Attempts 0`, `Exception is ArgumentException` (refused by pre-validation) | `false` | one line per rule | `validation` |
| `StatusCode 0`, `Attempts 0`, no exception (options unusable / token not resolvable, e.g. Key Vault outage) | `false` | `Mailer client not usable: …` (Error log) | `client-config` |
| any other failure | `false` | `API error {status}: {error}` | `api-error` |
| client threw | `false` | exception detail | `api-error` |
`reference` (`inv_<Id>` etc.) is the audit key and part of the Mailer's
idempotency key (server + reference + content, while the first entry is still
pending). A resend of the identical mail within that window answers 409 and
is reported as **success** — the first entry is delivered; nothing is sent
twice. A resend after delivery started is a new entry. Callers keep reusing
`inv_<Id>` (e.g. `req/resend`); no per-attempt reference is needed.
### SMS and verification
- `SendSmsAsync` pushes `PWMailerMessage { ComType = "s", To = mobile, Body }`
— the base validation requires no subject for `comType "s"`.
- `VerifyCredentialsAsync` → `IPWMailerClientService.VerifyAsync(serverId)`
(`GET /api/mailer/verify[?serverid=…]`), mapped to
`MailerCredentialsCheckResult` for the Admin system-status view.
### Observability
`fuchs.emails.sent` / `fuchs.emails.failed{reason}` with reasons
`invalid-email`, `disabled`, `validation`, `client-config`,
`attachments-mismatch`, `api-error`; `fuchs.email.send.duration{success}`. The OCORE
client additionally logs every failed call at Warning and exposes
`RequestSending`/`ResponseReceived`/`CallCompleted`/`CallFailed` events (not
subscribed by Fuchs today).
## Key files
- `Fuchs/Services/IComService.cs` — interface + `MailerCredentialsCheckResult`.
- `Fuchs/Services/ProcessWebComService.cs` — the adapter.
- `Fuchs/Services/FuchsMailerClient.cs` — `ValidateMessage` override.
- `Fuchs/Services/MailerAttachmentPolicy.cs` — limit rules (pure).
- `Fuchs/Services/ProcessWebComSettings.cs` — `Fuchs:Mailer` incl. `Attachments`.
- `Fuchs/Program.cs` — options mapping + `AddPWMailerClient<FuchsMailerClient>()`.
- `OCORE_web/OCORE_web/comm/pwmailer/` — the client (submodule).
- `Fuchs.Tests/ProcessWebComServiceTests.cs`, `Fuchs.Tests/FuchsMailerClientTests.cs`;
the wire format is tested in `OCORE_web/OCORE_webTests/comm/PWMailerClientServiceTests.cs`.
## Related decisions
- [0028 — push_com multipart attachments and attachment confirmation](../Decisions/0028-mailer-push-com-multipart-and-attachment-confirmation.md)
- [0029 — Outbound mail via the OCORE_web PWMailer client](../Decisions/0029-outbound-mail-via-ocore-pwmailer-client.md)
+75
View File
@@ -0,0 +1,75 @@
---
status: Active
lastUpdated: 2026-09-18
applyTo:
- "Fuchs/Program.cs"
- "Fuchs/appsettings*.json"
- "OCORE/OCORE/Secrets/**"
- "OCORE_web/OCORE_web/Secrets/**"
relatedDecisions:
- "0031-shared-keyvault-dpapi-secret-management.md"
---
# Secret management (Key Vault + DPAPI)
## Summary
Fuchs loads its secrets (connection strings, API keys, mailer tokens, ...)
from the shared Azure Key Vault `pcwkeys` at startup, with a DPAPI-encrypted
local cache as an offline fallback. The mechanism is shared, host-type-agnostic
infrastructure owned by the `OCORE`/`OCORE_web` submodules ([0031](../Decisions/0031-shared-keyvault-dpapi-secret-management.md));
this doc covers how Fuchs specifically wires into it and where to make
changes. The full mechanism reference (naming/encoding rules, DI wiring,
cache format) is maintained independently in
`OCORE_web/.github_instructions/secretsmanagement.instructions.md`.
## How it works
1. `Fuchs/Program.cs` calls `builder.AddSecretManagement()`
(`OCORE_web.Secrets.SecretManagementWebExtensions`) as the first step in
`ConfigureServices`, before `FuchsOcmsIntranet.Initialize(configuration)`
or anything else reads `IConfiguration`.
2. That call, backed by `OCORE.Secrets.Extensions.SecretManagementExtensions`,
registers two configuration sources in order: the local DPAPI cache
(`secrets.cache`, offline fallback) first, then the live Azure Key Vault
(overrides the cache when reachable) using `PrefixKeyVaultSecretManager`
to filter to secrets named `fuchs--...` and map them onto
`IConfiguration` keys.
3. `KeyVaultSyncService` (a registered `BackgroundService`) periodically
re-pulls every key listed in `Fuchs:SecretManagement:ManagedSecretKeys`
from Key Vault, re-writes the DPAPI cache, and calls
`IConfigurationRoot.Reload()` so a running process picks up rotated
secrets without a restart.
4. A secret whose name is only known at runtime (not a fixed
`IConfiguration` key) — e.g. resolved from a DB job record — is instead
fetched directly via `OCORE.Secrets.Extensions.SecretsOnDemandLoader`,
bypassing `ManagedSecretKeys` and the periodic sync entirely.
5. In `Development`, `appsettings.Development.json` (git-ignored) can
override any of these values locally, bypassing Key Vault for that key;
`AssembleConnectionStrings` (`Fuchs/Program.cs`) additionally prefers
`_Dev`-suffixed credential keys so a reachable Key Vault can never hand a
dev machine production DB credentials.
## Key files
- `Fuchs/Program.cs` — `builder.AddSecretManagement()` call site and
`AssembleConnectionStrings`.
- `Fuchs/appsettings.json` — `SecretManagement` block (`VaultUri`,
`AppName: "fuchs"`, `CacheFilePath`, `SyncIntervalHours`,
`ManagedSecretKeys`) plus `"MANAGED_BY_KEYVAULT"` placeholders for every
managed key.
- `OCORE/Secrets/Extensions/SecretManagementExtensions.cs` — shared
Key Vault + DPAPI wiring and DI registration, used by every host type.
- `OCORE/Secrets/Extensions/SecretsOnDemandLoader.cs` — direct, cached,
runtime-name secret lookup outside the `ManagedSecretKeys` sync path.
- `OCORE/Secrets/Sync/KeyVaultSyncService.cs` — periodic re-sync +
`IConfigurationRoot.Reload()`.
- `OCORE/Secrets/Cache/DpapiSecretsCache.cs` — DPAPI encrypt/decrypt of the
local cache file.
- `OCORE_web/Secrets/SecretManagementWebExtensions.cs` — the
`WebApplicationBuilder.AddSecretManagement()` entry point Fuchs calls.
- `OCORE_web/.github_instructions/secretsmanagement.instructions.md` —
canonical mechanism reference (naming/encoding rules, cache format,
"adding a new secret" steps), kept current independently of this doc.
- `.github/copilot-instructions.md` ("Azure Key Vault — Secret Naming") —
the naming rules summarized for this repo, with the `fuchs--` prefix.
## Related decisions
- [0031 — Shared Key Vault + DPAPI secret management via OCORE/OCORE_web](../Decisions/0031-shared-keyvault-dpapi-secret-management.md)
@@ -1,5 +1,5 @@
--- ---
status: Accepted status: Superseded
date: 2026-07-14 date: 2026-07-14
applyTo: applyTo:
- "Fuchs/code/InvoiceSetPricing.cs" - "Fuchs/code/InvoiceSetPricing.cs"
@@ -10,7 +10,7 @@ applyTo:
- "Fuchs/code/FuchsPdf.cs" - "Fuchs/code/FuchsPdf.cs"
- "Fuchs/js/intranet/**" - "Fuchs/js/intranet/**"
- "Fuchs/Docs/INVOICE_SET_PRICING.md" - "Fuchs/Docs/INVOICE_SET_PRICING.md"
supersededBy: "" supersededBy: "0019-set-price-modes-are-keyed-on-the-set.md"
--- ---
# 0009 — The two menu set-price modes are per-service-request-block, irreversible cache mutations that insert a dedicated set row # 0009 — The two menu set-price modes are per-service-request-block, irreversible cache mutations that insert a dedicated set row
@@ -0,0 +1,83 @@
---
status: Accepted
date: 2026-09-10
applyTo:
- "Fuchs/code/MigraDocExtensions.cs"
- "Fuchs/code/FuchsPdf.cs"
- "Fuchs/Services/InvoiceService.cs"
- "Fuchs/Services/IInvoiceService.cs"
- "Fuchs/Services/IPdfService.cs"
- "Fuchs/Services/FuchsPdfService.cs"
supersededBy: ""
---
# 0018 — Invoice-text images are fetched at render time from a domain whitelist, not embedded as data URIs
## Context
The invoice line-item description is TinyMCE-authored HTML (`FdsInvoiceData`/`InvoiceBlock.Items`
item `desc`), and can contain `<img src="https://portal.mobilefieldreport.com/mfr/...">` picture
links pulled from the mfr ERP — the browser resolves and displays them in the editor. The PDF
pipeline, however, renders through a hand-rolled HTML→MigraDoc subset converter
(`MigraDoc.Extensions.Html.HtmlConverter` in `MigraDocExtensions.cs`), which had no `img` handler
at all: every `<img>`, remote or embedded, was silently dropped, so images the editor showed never
appeared on the actual invoice.
MigraDoc/PdfSharp does not fetch remote URLs itself, and the converter runs synchronously deep
inside the largely-synchronous `FuchsPdf` static rendering pipeline. Fetching an arbitrary
user-supplied URL server-side is also an SSRF surface — the invoice text is user/editor-controlled,
so a naive "download whatever `src` says" would let it reach internal/private network addresses.
## Decision
- Invoice images are inlined into the PDF only when their `src` starts with the mfr REST root
(`InvoiceService.AllowedInlineImagePrefix`, e.g. `https://portal.mobilefieldreport.com/mfr/`) —
the only origin the invoice editor's picture links come from. This is derived from the same
`Fds:MFR_host` configuration entry the MFR REST client itself uses (via
`MFR_RESTClient.MFRClientConfig.RestRoot`), not a second hardcoded literal, so it can't drift if
the configured mfr host ever changes per environment/tenant. Anything else (any other host,
`http://`, a different path) is left unrendered, exactly as before this feature — no generic
remote fetch.
- The fetch happens once per invoice render, in `InvoiceService.ResolveInlineImagesAsync`
(async, off a named `IHttpClientFactory` client `"InvoiceInlineImages"`, 10s timeout), *before*
the synchronous MigraDoc render starts. `InvoiceService.GenerateInvoicePdf` therefore became
`GenerateInvoicePdfAsync` — every caller already ran in an async context. `<img>` sources are
found by parsing the description HTML with HtmlAgilityPack (`ResolveInlineImagesAsync`) — the
same library the `HtmlConverter` itself parses the identical text with — rather than a regex, so
a decoy attribute next to the real `src` (e.g. `data-src="..."`) can't be picked up in its place.
Each fetched image is capped at 5MB, must report an `image/*` content-type, and the invoice
fetches at most 20 distinct images; failures (unreachable, wrong type, oversized) are logged,
counted (`FuchsTelemetry.InvoiceInlineImageFetches`, tagged by outcome), and just leave that one
image out — they never fail the whole render. The distinct URLs fetch concurrently (capped at 4
in flight via a `SemaphoreSlim`, not one-at-a-time) since this runs inline on every render,
including repeated draft-preview renders — total resolve time is recorded in
`FuchsTelemetry.InvoiceInlineImagesResolveDuration`.
- The resolved `{src → "base64:..."}` map flows down as a plain `IReadOnlyDictionary<string,string>?`
parameter through `IPdfService.ApplyInvoice` → `FuchsPdf.ApplyInvoice` → `Cell.AddHtml` into the
`HtmlConverter`, whose new `img` handler only emits an image for a `src` present in that map —
it does no fetching itself and never sees the network. The converter itself stays generic (its
constructor parameter is named `resolvedImages`, not invoice-specific) — resolving *which* images
go in the map, and any network/whitelist policy, is entirely InvoiceService's concern, not the
converter's. Image sizing mirrors the HTML: the handler reads `width`/`height` (attribute or
inline `style`, as TinyMCE's resize handles write them) and converts CSS px → cm, capped to
7×10cm so a malformed/huge size can't blow out the invoice table layout.
## Consequences
- Any future host serving invoice-editor images must be added to the whitelist deliberately (a
one-line prefix, or a small allowed-prefixes list if a second origin appears) — this is not
meant to grow into a generic "fetch any URL" facility.
- `IInvoiceService.GenerateInvoicePdf` → `GenerateInvoicePdfAsync`, and
`IInvoiceDraftService.RenderPreview` → `RenderPreviewAsync` (same signature change, same reason)
are breaking signature changes; anything implementing/mocking these interfaces needs updating.
- Image src values not covered by the whitelist — including a stray non-mfr remote URL, or a raw
`data:` URI a user might paste in — still render as nothing, matching the pre-existing (silent)
drop behaviour. If embedded `data:` images need support later, that's a separate, simpler
decision (no network fetch involved) and can reuse the same `img` handler contract.
## Alternatives considered
- **Fetch inside the HTML converter itself (sync-over-async `HttpClient.GetAsync(...).Result`)** —
rejected: violates the project's no-sync-over-async rule and keeps SSRF-relevant network code
inside a generic, reusable HTML converter instead of a single audited call site.
- **Fully embed images as `data:` base64 URIs at editor save time** — rejected: doubles storage
size per invoice edit, and the image is already resolved by the mfr portal itself; re-fetching a
small number of images once at render time is simpler and keeps the source of truth external.
- **Allow any remote `http(s)://` host** — rejected on SSRF grounds; every invoice image the editor
can currently produce is from the mfr portal, so a whitelist costs nothing today.
@@ -0,0 +1,111 @@
---
status: Accepted
date: 2026-09-10
applyTo:
- "Fuchs/code/InvoiceSetPricing.cs"
- "Fuchs/code/InvoiceSetStructure.cs"
- "Fuchs/Services/InvoiceDraft*"
- "Fuchs/Services/IInvoiceDraft*"
- "Fuchs/code/InvoiceDraftSession.cs"
- "Fuchs/code/InvoiceDraftCalculator.cs"
- "Fuchs/code/FuchsPdf.cs"
- "Fuchs/js/intranet/**"
- "Fuchs/Docs/INVOICE_SET_PRICING.md"
supersededBy: ""
---
# 0019 — The two set-price menu modes are keyed on the set, not on the service-request block
## Context
ADR [0009](0009-block-setprice-modes-are-irreversible-mutations.md) defined the two menu modes
("Set mit Preis" / "Nur Set mit Preis") as per-**service-request-block** conversions: every block
was collapsed into one inserted "Gesamtumfang pauschal" set row carrying the block's whole sum,
every original line of the block was price-nulled or removed, and any existing set membership was
neutralised (`SetItmId`/`setId` cleared, existing set rows demoted). Grouping by `SetItmId` was
explicitly rejected there.
ADR [0016](0016-user-defined-set-structure.md) then made set membership an explicit, user-editable
parent link with a dialog to define exactly which line belongs to which set, and let sets nest. With
that in place, the block-wide collapse actively destroys the structure the user just defined: it
discards their sets, prices lines that were never meant to be in a set, and can never show two sets
of one service request as two lines.
The product owner has therefore redefined the two modes again, this time onto the set structure:
- Choosing **"Set mit Preis – Positionen ohne Preis"** must give **each set** one price, show that
set's positions without a price, and leave every item that is in **no** set listed individually
with its own price. Sets and single items together make up the invoice total.
- Choosing **"Nur Set mit Preis – Positionen ausgeblendet"** must hide the positions belonging to
the sets, while the sets themselves and the items outside any set stay visible.
- **Several sets stay several separate lines** — they are never merged into one.
This decision records that redefinition and **supersedes ADR 0009**. Function 1 (the inline
set-item switch) is carried over from 0009 unchanged, as is ADR 0008's rule that every calculation
happens server-side and the PDF renders exactly what the editor shows.
## Decision
There are still three set-price operations, but they now share one grouping key — the set — and one
conversion primitive:
1. **Inline set-item switch (`item.setprice`) — unchanged from ADR 0009**, except that it is now a
thin wrapper over the shared primitive and is a **no-op on an already-converted set** (own price
non-zero). Without that guard a repeated delta would re-sum the members the first conversion had
already nulled and reset the set price to 0.
2. **"Set mit Preis" (`block.setprice`) — per set, irreversible.** For **every still-unconverted
top-level set of every block**: sum the set's transitive descendants onto the set line and set
each descendant's price fields to `null` (empty cell, excluded from the sum — never `0`, which
would print a genuine `0,00 €`). The descendant rows are kept.
3. **"Nur Set mit Preis" (`block.setonly`) — per set, irreversible.** As above, and additionally
**remove** the whole subtree of every top-level set (its members and any nested set line) from
both line shapes (`itm` and `items`). A set that was already converted keeps its price and still
has its members removed.
Properties common to modes 2 and 3:
- **The set is the grouping key.** No row is inserted, no row is demoted, no `SetItmId`/`setId` is
cleared. The user-defined structure (ADR 0016) is what the modes act on and what survives them.
- **Lines outside every set are untouched** — they keep their individual price and stay listed as
ordinary positions. Sets and standalone lines together form the total.
- **Only top-level sets are priced.** A nested set is part of its parent's subtree and is
blanked (mode 2) or removed (mode 3) with it, so no value is ever counted twice.
- **Empty sets and set-free invoices are no-ops** — no version bump, no history entry. The editor
therefore checks the authoritative state for a set before opening the mode dialog and otherwise
tells the user to define a set first ("Set-Struktur bearbeiten").
- **Repeatable, still one-way.** Running a mode again converts only sets that are still
unconverted, so the menu entry stays available (the ADR 0009 `admin.setmode` gate on the entry is
gone); no operation converts anything back.
- **Mutation, not display.** The change is written into `InvoiceDraftSession.Req` server-side; there
is no render-time grouping flag. A residual `setmode:` `InvoiceOptions` token from the pre-0009
display-mode model still degrades safely (ignored) and is not re-persisted.
- **Total conserved, editor and PDF identical** (ADR 0008): a blanked member contributes nothing, a
removed one is gone, and in both cases its value already sits on the set line.
## Consequences
- `InvoiceDraftEditService.ApplyBlockSetPricing` (block aggregation, inserted `bset_*` row,
`DemoteSet`, `setId` neutralisation) is gone; `ApplySetPricing` walks the set forest through
`InvoiceSetStructure` and delegates the per-set work to `ConvertSetHeader`, which
`ApplyItemSetPrice` now uses too. One conversion rule, three triggers.
- Because membership is preserved, `InvoiceSetPricing.Build` (and `BuildSetDisplay`) governs the
rendering of the result again: a converted set's members are price-blanked, non-set lines keep
their own price. The PDF path needs no special case for the menu modes.
- The "Gesamtumfang pauschal" label and the notion of a service request as an implicit set no longer
exist. A user who wants one price for a whole service request creates a set over its lines in the
structure dialog first.
- Tests: `InvoiceDraftServiceTests` covers per-set pricing with a standalone item alongside, several
sets kept separate, nested sets priced once, member removal in both line shapes, the
already-converted set, the repeated `item.setprice` no-op, and the set-free/empty-set no-ops.
## Alternatives considered
- **Keep the block collapse and let the user pick the block** — rejected: it still cannot express
two sets in one service request, and it overwrites the structure ADR 0016 exists to let the user
define.
- **Price nested sets individually as well** — rejected: their value is already contained in the
top-level set's price, so both being priced would double the invoice's printed sum.
- **Fall back to the old block behaviour when an invoice contains no set** — rejected: it would make
the same menu entry do two very different things. Without a set there is nothing to price as a
set, so the modes are a no-op and the editor points the user at the structure dialog.
- **Make the modes reversible display toggles again** — rejected (as in ADR 0009): the set price
must be a real, hand-editable value in the document, and "Nur Set mit Preis" must actually drop
the member lines.
@@ -0,0 +1,91 @@
---
status: Accepted
date: 2026-09-14
applyTo:
- "Fuchs/Controllers/IntranetController.AdminUsers.cs"
- "Fuchs/js/intranet/modules/fis.usr*.js"
- "Fuchs/js/intranet/modules/fis.usr.scss"
- "Fuchs_Database/dbo/Stored Procedures/fis_admin_createUserAccount.sql"
- "Fuchs_Database/dbo/Stored Procedures/fis_admin_delUserAccount.sql"
supersededBy: ""
---
# 0020 — User management: wire up create/delete, welcome email on create, guard delete
## Context
The `usr` module (see [0015](0015-split-admin-user-management-module.md)) let an admin list
users, reset a password, change an email address, and manage group membership, but had no way
to add a new account or remove one — the admin had to do that directly in the database. Two
stored procedures for this already existed in `Fuchs_Database` but were completely unwired from
any controller: `fis_admin_createUserAccount` and `fis_admin_delUserAccount`. Neither followed
the module's established conventions:
- `fis_admin_createUserAccount` took no `@authaccount` and performed **no** `intranet_admin`
authorization check at all (every other write in this module checks
`fis_admin_getUserAuth(@authaccount) > 1`, per [0014](0014-admin-user-management.md)'s dual
auth axis), had no duplicate-email guard (`fis_admin_setUserAccount` does), and generated the
initial password inline in the `INSERT` without ever returning it — so nothing calling it
could email the new user their credentials.
- `fis_admin_delUserAccount` likewise had no `intranet_admin` check, and nothing stopped an
admin from deleting their own account.
## Decision
- **`create` and `delete` are new `id` cases in `Do_Process_AdminUsers`**, behind the same
`fds_sys > 4` gate as every other action in the module.
- **`fis_admin_createUserAccount` was brought in line with its siblings** rather than reused via
`fis_admin_setUserAccount`'s existing "insert when `@useraccount_id` is NULL" branch (which
would have been the smaller diff): it gained `@authaccount` + the standard
`fis_admin_getUserAuth(@authaccount) > 1` gate, the same duplicate-email check
`fis_admin_setUserAccount` already has, and — mirroring `fis_admin_resetUserPassword`'s
existing pattern — resolves `@password` (auto-generating one via `ocms_fn_generatePassword`
when the admin leaves it blank) **before** encrypting it, and returns it in the result set as
`newpassword` alongside the created row. `fis_admin_setUserAccount`'s insert branch was left
alone (still reachable in principle, but has no caller) since changing its shared behavior
risked the existing `email` action; a dedicated create proc with an explicit contract was
judged clearer than overloading an update proc's edge case.
- **New account creation always emails the credentials, never displays them** — identical
handling to password reset ([0014](0014-admin-user-management.md)): `HandleLogin`-style
`IComService.SendEmailAsync` (respecting `Fuchs:Email:OverrideRecipient`) sends name, email,
and password via `IntranetController.BuildWelcomeEmail`; on delivery failure the admin gets an
`IEventService.UserIssueAsync` notification per
[0003](0003-interrupting-exceptions-notify-the-user.md), matching `pwdreset`'s existing
`emailed` flag semantics.
- **`fis_admin_delUserAccount` gained the same `intranet_admin` gate** (previously entirely
missing — a real authorization hole once the proc is reachable from a controller) **and a
self-delete guard** (`@authaccount <> @useraccount_id`), enforced at both the SQL layer (belt)
and the controller (suspenders — returns 409 `cannot_delete_self` without a round-trip when the
caller targets their own account).
- **Frontend**: `fis.usr.js`'s base ribbon gained an always-visible "Neuer Benutzer" button
(`createDlg`, a `$ocms.dlgform` collecting Vorname/Nachname/E-Mail required, Titel/Anrede/Mobil
optional); the per-row ribbon gained "Benutzer löschen" (`deleteUser`), using the same
`confirm(...)` guard pattern Banking's `fis.bam.js` uses before destructive actions
(`smd`/`mbd`) rather than a modal dialog, since delete has no fields to collect.
## Consequences
- `fis_admin_createUserAccount`'s signature changed (`@authaccount` added as the first
parameter) and its result set gained a `newpassword` column; safe in-place change since it had
no existing caller.
- `fis_admin_delUserAccount` now silently no-ops (same "no-op instead of error" style as every
other `fis_admin_*` proc in this module) for a caller without `intranet_admin >= 2` **or** who
targets their own account — the controller's own `cannot_delete_self` check makes the
self-delete case surface as a clear error to the admin instead of a silent no-op, but a caller
lacking `intranet_admin` still only sees an unexplained failure, consistent with the accepted
gap from 0014.
- Delete is a soft-delete (`fis_admin_delUserAccount` already set `DateDeleted`/`UserDeleted`
before this change) — deleted accounts simply stop appearing in `fis_admin_getUserAccountList`
and can only be restored directly in the database; no "undo" is exposed in the UI.
- The list view (`fis.usr.scss`) was restyled from a plain bordered `<table>` to a card-style row
grid (avatar-initial badge, status pill, hover/selection highlighting) matching the visual
language `fis.admin.scss` already established (`adm_pill`, `adm_card`) — a pure presentation
change, the DOM is still built by the same `renderLst`/`$ocms.postXT('usr/list')` flow.
## Alternatives considered
- **Reuse `fis_admin_setUserAccount` for create** by calling it with `@useraccount_id = NULL`.
Rejected — its insert branch doesn't return the generated password (nothing to email to the
new user), and giving one endpoint two very different response shapes depending on a hidden
parameter was judged more surprising than a small, explicit `create` proc.
- **Hard-delete instead of soft-delete.** Not considered — `fis_admin_delUserAccount` already
implemented soft-delete before this change and every other user-facing list in the app
(`fis_admin_getUserAccountList` et al.) already filters on `DateDeleted is null`; changing to a
hard delete would be an unrelated, larger change.
- **Let an admin delete their own account and rely on another admin to recover.** Rejected —
trivially avoidable lockout risk with no upside.
@@ -0,0 +1,88 @@
---
status: Accepted
date: 2026-09-16
applyTo:
- "Fuchs/Controllers/IntranetController.AdminGroups.cs"
- "Fuchs/js/intranet/modules/fis.usrgrp*.js"
- "Fuchs/js/intranet/modules/fis.usrgrp.scss"
- "Fuchs/js/intranet/fis_main_menu.js"
- "Fuchs_Database/dbo/Functions/fis_admin_getUserGroupList.sql"
- "Fuchs_Database/dbo/Stored Procedures/fis_admin_setUserGroup.sql"
- "Fuchs_Database/dbo/Stored Procedures/fis_admin_delUserGroup.sql"
- "Fuchs_Database/dbo/Stored Procedures/fis_admin_addGroupMember.sql"
- "Fuchs_Database/dbo/Stored Procedures/fis_admin_remGroupMember.sql"
supersededBy: ""
---
# 0021 — User group management: new `usrgrp` module, close the missing `intranet_admin` gate on group writes
## Context
The per-user "Gruppen" dialog in the `usr` module ([0014](0014-admin-user-management.md),
[0015](0015-split-admin-user-management-module.md)) lets an admin toggle an existing user's
membership in each usergroup, but there was no UI anywhere to create, rename or delete a
usergroup itself — `fis_usergroups` could only be populated by running a raw SQL script by hand
(`hlp/db/create user group.sql`). The DB-layer proc `fis_admin_setUserGroup` (create/rename) and
the read-side `fis_admin_getUserGroupList` already existed but were wired to no controller
endpoint, mirroring the exact gap [0020](0020-user-management-create-and-delete.md) closed for
user accounts.
While wiring this up, three of the four `fis_admin_*` group-write procedures were found missing
the module's standard `intranet_admin` authorization check entirely (`fis_admin_addGroupMember`,
`fis_admin_remGroupMember`, `fis_admin_delUserGroup`) — a caller with `fds_sys > 4` but no
`intranet_admin` setting could add/remove a user's group membership or delete a group outright,
the same real authorization hole 0020 fixed for `fis_admin_delUserAccount`. `fis_admin_setUserGroup`
already had the check.
## Decision
- **New standalone module, `fn=usrgrp`** (`IntranetController.AdminGroups.cs`,
`fis.usrgrp.js`/`fis.usrgrp_txt_de.js`/`fis.usrgrp.scss`), added as a third entry in the existing
"Administration" sidemenu dropdown alongside Benutzerverwaltung and Systemüberwachung — per
[0015](0015-split-admin-user-management-module.md)'s own stated consequence that any future
admin-only feature should default to its own module under that dropdown rather than be folded
into an existing one. The dropdown itself already stays visible while any of its children is
open, so switching between Gruppenverwaltung and Benutzerverwaltung needs no dedicated "back"
control.
- **Same dual-authorization gate as every other module under this dropdown**: `fds_sys > 4`
server-side (`AdminMinAuthExclusive`/`GetSystemAdminAuthAsync`, shared from
`IntranetController.Admin.cs`) gates whether the module is reachable/visible at all;
`fis_admin_getUserAuth(@authaccount) > 1` (the `intranet_admin` setting) independently gates
every read/write the `fis_admin_*` procs perform. Both together are what "only admins can see
and change groups" means in this codebase — see the Consequences below for why this is enforced
in four places, not one.
- **New `id` cases in `Do_Process_AdminGroups`**: `list` → `fis_admin_getUserGroupList`, `create`/
`rename` → `fis_admin_setUserGroup` (same proc; `rename` supplies an existing `@usergroup_id`),
`delete` → `fis_admin_delUserGroup`. No membership editing here — that stays in the `usr`
module's existing per-user checkbox dialog, unchanged.
- **Closed the missing `intranet_admin` gate** on `fis_admin_addGroupMember`,
`fis_admin_remGroupMember` and `fis_admin_delUserGroup` by adding the same
`fis_admin_getUserAuth(@authaccount) > 1` guard `fis_admin_setUserGroup` already had, following
the same "no-op instead of error" style 0020 used for `fis_admin_delUserAccount`: a caller
without `intranet_admin >= 2` gets an unmodified result set / a `false` success bit, not a SQL
error, consistent with every other `fis_admin_*` proc in this area.
- **Frontend** mirrors `fis.usr.js`'s list+ribbon pattern (`renderLst`/`eM`/`iMn`): a card-style
row list with a base ribbon button ("Anlegen") and a per-row ribbon ("Umbenennen"/"Löschen") on
row selection, using `$ocms.dlgform` for create/rename and a `confirm(...)` guard (no dialog) for
delete — the same choice 0020 made for deleting a user account.
## Consequences
- Deleting a group does **not** guard against it still having members — its `fis_usergroups_members`
rows simply stop being returned (the membership query already filters on the group's own
`DateDeleted`). No UI currently surfaces "this group has N members" before deletion; acceptable
since deletion is a soft-delete and the group row can be recreated with the same effective
membership restored via `fis_admin_addGroupMember` if this turns out to matter in practice.
- Four separate `fis_admin_*` procs/functions now each independently re-check
`intranet_admin > 1` (`getUserGroupList`, `setUserGroup`, `delUserGroup`, `addGroupMember`,
`remGroupMember` — five, in fact) rather than a single shared gate; this is the established
style in this codebase (every `fis_admin_*` object re-checks for itself, see 0014) and not
something this change revisits.
- Same operational note as 0014/0015/0020: an account needs **both** `fds_sys > 4` and
`intranet_admin >= 2` to actually manage groups, not obvious from the UI.
## Alternatives considered
- **Extend the existing per-user "Gruppen" dialog** with an inline "new group" input and a
delete icon per row, instead of a new module. Rejected per explicit user direction — the user
asked for a dedicated sidemenu entry for group management, separate from the per-user dialog.
- **Fold into the `usr` module** as a second ribbon button. Rejected for the same reason 0015
split `usr` off `admin` in the first place: two unrelated concerns (managing individual user
accounts vs. managing the group catalog) behind one button was already identified as a mistake
once it happened once; the "own module under the dropdown" default from 0015 applies directly.
@@ -0,0 +1,65 @@
---
status: Accepted
date: 2026-09-16
applyTo:
- "Fuchs_Database/dbo/Stored Procedures/mfr__updt__*.sql"
- "Fuchs_DataService/FdsMfrClient.cs"
supersededBy: ""
---
# 0022 — MFR sync transaction blocking during full resets is an accepted risk
## Context
Every `mfr__updt__*` stored procedure (25 of them, one per synced mfr entity table) wraps its
DELETE-stale-references + MERGE-upsert logic in a single explicit `BEGIN TRANSACTION` /
`COMMIT TRANSACTION`, with `SET XACT_ABORT ON` and a `TRY/CATCH` that rolls back and logs to
`fds__admin_dberrors` on failure. This gives all-or-nothing atomicity for one entity's sync
batch: either every stale side-table row is cleaned up and the target table is upserted, or
none of it is, so a mid-batch failure can never leave orphaned side-table rows alongside a
stale/missing target row.
`FdsMfrClient.cs`'s `CommandAfter` (which invokes the `mfr__updt__*` proc) and `CommandBefore`/
staging `SqlBulkCopy` both use a 600s command timeout (`MergeCommandTimeoutSeconds`), raised
from `SqlCommand`'s 30s default specifically so a **full reset** of a large entity table (not
the normal incremental sync, which only carries the rows that changed since the last run) has
room to complete. Before that timeout was raised, a slow full-reset batch would self-abort at
30s — the client cancellation would cancel the batch and roll back the open transaction —
capping how long the transaction (and the locks it holds on the target table and the affected
side tables) could stay open. With the timeout at 600s, a genuinely large full-reset batch can
now hold that same transaction, and therefore those same locks, for up to ten minutes, during
which other readers of the live `mfr__*` tables (e.g. intranet pages querying companies,
contacts, invoices, …) can block waiting for the write transaction to release its locks —
instead of blocking, at most, on one DELETE or one MERGE statement individually.
## Decision
Accept this as a known, documented risk rather than changing behavior. Full resets are rare
(a manual/administrative operation, not the routine incremental sync path) and are expected to
run outside of business hours. The atomicity the transaction provides — no orphaned side-table
rows left behind by a failed sync — is worth more than shortening the worst-case blocking
window for an operation this infrequent.
## Consequences
- A full reset of a large `mfr__*` entity table can block ordinary intranet reads of that
table (and the side tables `mfr__*PartnerSet`, `mfr__#customvalues`, `mfr__#locations`,
`mfr__comments`) for as long as that reset's DELETE+MERGE takes to execute, up to the 600s
command timeout.
- Anyone scheduling or manually triggering a full reset of a large entity table should do so
during low-traffic hours, same as any other maintenance operation with table-level locking.
- If full resets become frequent enough, or large enough, for this blocking window to matter in
practice, revisit this decision — options considered and rejected here (see below) remain
available.
## Alternatives considered
- **Drop the explicit transaction**, letting each DELETE/MERGE statement auto-commit
individually (the behavior before the transaction wrapping was added). Shortens the lock
window back to per-statement, but reintroduces the exact orphaned-data risk on mid-batch
failure that the transaction wrapping was added to prevent. Rejected — full-batch atomicity
was a deliberate improvement and giving it up trades a rare, low-traffic-hours blocking issue
for a correctness regression on every sync.
- **Enable Read Committed Snapshot Isolation (RCSI)** (`ALTER DATABASE ... SET
READ_COMMITTED_SNAPSHOT ON`) so readers use row versioning instead of blocking on the writer's
locks, keeping the transaction's atomicity intact while removing reader blocking entirely.
This is the architecturally cleanest fix, but it is a database-wide isolation behavior change
(affects every table, every query, tempdb version-store load) that needs its own testing and
sign-off rather than being folded into this change. Left as the preferred follow-up if the
accepted risk above turns out not to be acceptable in practice.
@@ -0,0 +1,77 @@
---
status: Superseded
date: 2026-09-16
applyTo:
- "Fuchs/Controllers/**"
- "Fuchs/code/FuchsIntranet.cs"
- "Fuchs/js/intranet/**"
supersededBy: "0024-multilogger-sql-log-sink.md"
---
> **Superseded by [0024](0024-multilogger-sql-log-sink.md).** The explicit `Fuchs_intranet.debug_log` /
> `fds__admin_logdebug` DB write this decision introduced was retired the same day, once
> [0024](0024-multilogger-sql-log-sink.md) made `ILogger` itself the single path to the DB log
> (`fds__admin_logdebug_ext`, same `fds__debuglog` table). Keeping both would have double-written
> every row. The frontend/`x-ocms-desc`/`EncodeResponseDescription`/preview-timeout decisions below
> are unaffected and still in force — only the *DB write mechanism* changed.
# 0023 — Failures of user actions are persisted to `fds__admin_logdebug`, with the SQL and a readable description
## Context
Support diagnoses production problems from a query over the `fds__admin_logdebug` table
(`EXECUTE [dbo].[fds__admin_logdebug]` via `Fuchs_intranet.debug_log`) — not from the
web server's `logs/ErrorLog.txt` ([0003](0003-structured-diagnostic-logging.md)), which is
rarely reachable. Reviewing the 2026-09-16 production extract of that table showed three gaps:
- Every "SQL Error in getSQLDataSet" row (twelve execution timeouts in one morning) had
`Data = NULL`. `FIS_SQLOptions.OnError` logged its constructor *context* (the fn/id/code,
usually `null`) instead of the *data* OCORE hands over (`new { sql = … }`), so nobody could
tell which statement timed out.
- Unhandled exceptions from `IntranetController.Do` went to `ILogger` only. A customer's
failing "PDF-Vorschau" left no trace in the table at all.
- The browser's generic failure alert reads its description from the `x-ocms-desc` response
header, which the backend never set — so users always saw an empty
"Der Server hat einen Fehler gemeldet:".
## Decision
- **The DB log row must carry what is needed to attribute the failure.** `FIS_SQLOptions`
merges the OCORE payload (the SQL statement) with the caller context into the `Data`
column (`{ sql, context }`). New `OnError` hooks must never drop the statement.
- **Every failure that interrupts a user action is written to `fds__admin_logdebug` too**,
in addition to `ILogger` and the notification required by
[0003](0003-interrupting-exceptions-notify-the-user.md): the `Do` catch-all writes
`Unhandled exception in Do {fn}/{id}` with route, user and the form *field names* (never
values — they can carry tokens/PII); the PDF-preview endpoints go through a shared guard
(`IntranetController.PdfPreviewAsync`) that logs `PDF preview failed (endpoint)` with
ref/user/duration, counts `fuchs.pdf.previews{outcome}` (`success`/`gone`/`failure` — an
expired-draft `410` from inside `render()` is its own `gone` outcome, not `success`, since
it is not the render failure this guard exists to catch) and, for an authenticated caller,
toasts the user.
- **A 500 carries its message in the `x-ocms-desc` header** (`ServerError(msg)` →
`EncodeResponseDescription`: percent-encoded ASCII, spaces as `%20` because the client
maps `+` to space before `decodeURIComponent`). Handlers that return a 500 should pass a
user-readable German message; the frontend shows it verbatim.
- **Preview calls in the frontend never fail silently.** They use a 120 s timeout
(`$inv.pdfPreviewTimeout`), one shared error handler (`$inv.pdfPreviewFailed`) and a
`complete` handler that unfreezes the editor and reports a timeout — `postXT` returns early
on a timeout without calling `error`, so `complete` is the only place it is observable.
## Consequences
- Support can attribute a SQL timeout to its statement straight from the table; a failing
preview is visible as one row with endpoint, reference id, user and duration.
- `debug_log` is a synchronous DB round-trip and is only used on error paths; it must not be
added to hot/success paths (see [0003](0003-structured-diagnostic-logging.md) on the cost
of DB logging).
- `x-ocms-desc` is user-facing: messages must be German, short, and free of secrets. The
innermost exception text is acceptable for an authenticated intranet user, but some
preview endpoints (`req/idoc`, `inv/rdoc`, `inv/rdocn`, `rem/idoc` — see `_allowedGet`
in `IntranetController.cs`) are reachable without authentication; `PdfPreviewFailureMessage`
omits the exception detail for those callers and falls back to the generic message.
## Alternatives considered
- **Enable `FuchsLoggerProvider.DatabaseLoggingEnabled`** so every `ILogger` error lands in
the DB. Rejected: it would write every `LogError` (including expected/handled ones and
background-service noise) with a DB round-trip each, and the provider has no access to the
request context (user, route) that makes a row actionable.
- **Return the message only in the JSON body.** The generic client failure path never parses
the body of a non-2xx response; the header is the existing contract in `oci_basic.js`.
@@ -0,0 +1,114 @@
---
status: Accepted
date: 2026-09-16
applyTo:
- "Fuchs/Program.cs"
- "Fuchs/Logging/**"
- "Fuchs/code/FuchsIntranet.cs"
- "Fuchs/Controllers/**"
- "Fuchs_DataService/FdsMfr.cs"
- "OCORE/OCORE/logging/**"
- "Fuchs_Database/dbo/Tables/fds__debuglog.sql"
- "Fuchs_Database/dbo/Stored Procedures/fds__admin_logdebug_ext.sql"
supersededBy: ""
---
# 0024 — `ILogger` is the single path to the DB log, via the OCORE MultiLogger SQL sink
## Context
[0023](0023-user-action-failures-persisted-to-db-log.md) fixed the immediate gaps (SQL statement
missing from the log row, unhandled `Do()` exceptions and PDF-preview failures never reaching the
DB) by having a handful of call sites call **both** `_logger.LogError(...)` **and** the explicit
`Fuchs_intranet.debug_log(...)` (→ `EXECUTE [dbo].[fds__admin_logdebug]`) for the same failure.
That works, but only for the call sites someone remembered to wire up by hand, it writes every
attributed failure to `fds__debuglog` twice as soon as any generic DB sink is added on top, and it
leaves the ~15 `new FIS_SQLOptions()` call sites (no logger, no context — see `Services/*.cs`)
without DB attribution at all, since their `dbLog` closure bypassed `ILogger` entirely.
OCORE ships a generic `ILoggerProvider` pipeline for exactly this (`OCORE.MultiLogger`,
`SqlLoggerProvider` + `FileLoggerProvider`) that other ProcessWeb projects (VM_Regatta) already
use: every `_logger.LogX(...)` call, from anywhere in the app or its dependencies (OCORE's own
SQL helpers included), can be mirrored to a SQL sink automatically, with per-provider level
filtering, structured context merging (scopes + `{Placeholder}` args + an explicit `data:`
object), and redaction. It was not previously wired into Fuchs.
## Decision
- **`ILogger` is the only thing a call site writes.** No code calls `Fuchs_intranet.debug_log`
(removed) or a stored procedure directly for attribution logging. `_logger.LogWarning`/`-Error`/
`-Critical` is sufficient; the SQL sink below persists it.
- **The OCORE MultiLogger SQL sink is registered at Warning+**, gated by
`Fuchs:Logging:SqlSink:Enabled` (default `true`), writing through the new
`[dbo].[fds__admin_logdebug_ext]` proc into the **same** `[dbo].[fds__debuglog]` table
the retired `fds__admin_logdebug` wrote to — existing report queries over that table keep
working, now enriched with `LogLevel`/`Machine`/`Category`/`AuthUser`/`IsDebug` columns.
`fds__admin_logdebug` itself is left in place (unused by the app, but not dropped — a
compatibility no-op for anything external still calling it).
- **`FuchsLoggerProvider`'s file sink (0003) is unchanged** and stays the always-on baseline;
the SQL sink is additive, not a replacement.
- **A call site that wants its own CodeReference gives the log call an explicit `EventId`**
(`new EventId(0, "SQL Error in getSQLDataSet")`, etc.) — its `Name` becomes the row's
`CodeReference`, preserving the exact strings the retired `debug_log` calls used, so nothing
downstream (dashboards, saved queries) had to change. Falls back to the `ILogger` category
(new `LogEntry.Category`, OCORE-side), then the constant `'MultiLogger'`.
- **`RequestLogScopeMiddleware`** (`Fuchs/Logging/`, registered after `UseAuthentication()`)
pushes `{requestId, path, authuser}` as an `ILogger` scope for the whole request — every log
line written while handling it, including ones written deep inside OCORE (SQL timeouts, PDF
render failures), is attributed automatically without the call site doing anything. `authuser`
specifically is picked up by OCORE's `MultiLogger_base.Log` into the dedicated
`LogEntry.AuthUser` → `fds__debuglog.AuthUser` column, not just the serialized JSON blob.
- **`FIS_SQLOptions.OnError` falls back to the static `OCORE.Logging.Logger`** (already set once
in `Program.cs`) when no request/service-scoped `ILogger` was supplied, so the `new
FIS_SQLOptions()` call sites keep working exactly as attributed as the ones that pass a logger.
- **`Fuchs_DataService/FdsMfr.cs`'s `FdsSqlOptions.OnError`** — the MFR-sync worker's equivalent of
`FIS_SQLOptions`, and the actual source of the deadlock/timeout rows the original log review
found — had the exact same problem in reverse: it called *both* `logger?.LogError(...)` *and*
`FdsDebug.DebugLog(...)` (a second, direct `EXECUTE [dbo].[fds__admin_logdebug]`) for every SQL
error. Since the worker is hosted inside this same web app (ADR 0010) and therefore shares the
SQL sink, that combination would double-write every row. Fixed the same way: `ILogger` only,
with the same static-logger fallback, `EventId.Name = procedure` (unprefixed, matching what the
direct `DebugLog` call used to pass).
- **`FdsDebug.DebugLog`, used elsewhere in `Fuchs_DataService`** (`FdsMfr.cs`, `FdsShared.cs`) was
deliberately **not** migrated — those call sites don't separately call `ILogger`, so there is no
duplication there today, and moving nine call sites onto the generic pipeline was out of scope
for closing the one hazard (`FdsSqlOptions.OnError`) this ADR set out to fix. It still writes
directly to the legacy `fds__admin_logdebug` proc (same `fds__debuglog` table) plus a local
`DebugLog.txt`. Revisit if a future change adds an `ILogger` call alongside one of those sites.
- **OCORE-side hardening that made this safe to turn on** (see the code-review this ADR grew out
of): `LogRedaction` masks sensitive keys by *segment*, not substring (`Pin`/`Otp` as a whole
identifier segment, not `Mapping`/`HotPath`), degrades one unserializable context entry to its
`ToString()` instead of dropping the whole payload, and tolerates a self-referencing graph.
`LogEntry.dataSerialized` is computed lazily (at most once, only if a sink actually reads it) so
the per-call context-merging cost is paid only when something is written. Scope/state collection
never throws into the caller. `SqlLoggerProvider`'s new `IncludeCategory` option is **off by
default** in OCORE (other consumers' stored procedures don't declare `@category`) — Fuchs turns
it on explicitly since its proc does.
- **Never use `{@Foo}` placeholders.** Plain `Microsoft.Extensions.Logging` (what OCORE.MultiLogger
and Fuchs use — not Serilog) does not treat `@` as a destructuring operator; it stays a literal
leading character of the property name (`"@Foo"`), which is confusing in the serialized JSON.
Use `{Foo}`.
## Consequences
- One `_logger.LogError(...)` call is enough; adding a second, explicit DB write next to it
duplicates the row once the SQL sink is on. Code review should flag a reintroduced
`debug_log`-style call.
- A log call that should be individually attributable in `fds__debuglog.CodeReference` needs an
explicit `EventId`; without one it falls back to the (often less specific, but still useful)
`ILogger` category.
- The SQL sink is a fire-and-forget async write with a 2-minute retry cooldown on failure
(`SqlLoggerProvider`) — during a DB outage it goes quiet for that window, same as any other
write to that DB. The file sink (0003) remains the log that is never gated on the DB being
reachable at all.
- `Fuchs:Logging:SqlSink:Enabled` is the escape hatch if the sink ever needs to be turned off
without a deploy (e.g. investigating whether it is itself contributing to DB load).
- OCORE's `MultiLoggerOptions.SQL.IncludeCategory` is a new, opt-in, backward-compatible knob;
other `SqlLoggerProvider` consumers (VM_Regatta) are unaffected unless they opt in and add a
matching `@category` parameter to their own stored procedure.
## Alternatives considered
- **Keep the explicit `debug_log` write and just fix its parameter bug (0023's original scope).**
Rejected once the generic SQL sink became the actual plan — keeping both would permanently
double-write every attributed failure, and still leave the `new FIS_SQLOptions()` call sites
unattributed.
- **`FuchsLoggerProvider.DatabaseLoggingEnabled = true`** (0003's prepared-but-disabled DB path).
Rejected again here for the same reason 0023 rejected it: no per-request context (user, route),
and it would fire on every `LogError` including background-service noise with no Warning+ gate.
@@ -0,0 +1,79 @@
---
status: Accepted
date: 2026-09-16
applyTo:
- "Fuchs/Controllers/IntranetController.Invoices*.cs"
- "Fuchs/Controllers/IntranetController.Requests.cs"
- "Fuchs/Services/InvoiceService.cs"
- "Fuchs_Database/dbo/Stored Procedures/fds__getReportDocument.sql"
- "Fuchs_Database/dbo/Stored Procedures/fds__setInvoiceFile.sql"
supersededBy: ""
---
# 0025 — Invoice document endpoints regenerate a missing final PDF on read
## Context
A finalised invoice's PDF is rendered exactly once, at finalisation
(`req/sconf` → `StoreInvoiceDocumentFileAsync` → `fds__setInvoiceFile`), and
stored in `fds__invoices.[file]` (primary) plus blob storage (archive). If that
single write fails — observed on 2026-09-16 for `W7VAL`/`R2026-0258`, where the
`fds__setInvoiceFile` call timed out under DB load — the invoice is left
finalised with `[file] = NULL` and nothing ever retries.
The read endpoints then disagreed: `req/idoc` regenerated a missing file
(a fallback carried over from the legacy VB `fuchs_ocms_intranet_requests.vb`
"idoc" case), while `inv/rdoc` — the endpoint the invoice list's preview icon
actually calls — read `fds__getReportDocument` and returned a permanent 404.
## Decision
Both invoice document read endpoints (`req/idoc`, `inv/rdoc`) regenerate and
persist the PDF via `StoreInvoiceDocumentFileAsync` when no stored bytes exist,
**only** when the invoice is
- **finalised** (`IsFinal`, i.e. `!FdsInvoiceData.IsDraft`), and
- **not external** (`IsExternal` false — an MFR-sourced document has no
template to regenerate from; a missing MFR file stays a 404).
A read is therefore allowed to write `[file]`, upload the blob and emit
`InvoiceFileCreated` — the same side effects finalisation would have had.
Every failure inside `StoreInvoiceDocumentFileAsync`/`StoreReminderDocumentFileAsync`
(render threw, render produced no bytes, DB write threw, DB write matched 0
rows) is logged at Error level individually, because the method signals all
four to callers with the same empty array. The 0-rows case is detected via
the procs' existing `OUTPUT inserted.[Id]` result set (read with
`getSQLDatatable_async`; empty ⇒ the `UPDATE ... WHERE [file] IS NULL`
matched nothing). Rows-affected is **not** usable for this: both procs run
`SET NOCOUNT ON`, so `ExecuteNonQuery` returns -1 whether or not the
`UPDATE` matched, which is why `setSQLValue_async` could never distinguish
a no-op from a write and the original incident was invisible to every log.
## Consequences
- **Drafts are never persisted by a read.** `fds__setInvoiceFile` writes
`WHERE [file] IS NULL` and `fds__setInvoiceFinal` never clears `[file]`; a
draft render stored early would make the later final write a no-op and the
invoice would carry the watermarked draft PDF forever. The `!IsDraft` gate is
load-bearing — do not relax it.
- The regenerated PDF is rendered from the invoice's *current* data. That is
acceptable because a finalised invoice is immutable in the editor; it is not
acceptable for anything mutable.
- A read on a cold miss now costs a render plus a DB write, so it is exposed
to the same `fds__setInvoiceFile` timeout that caused the miss. The client
preview timeout (`$inv.pdfPreviewTimeout`) must stay generous (120 s).
- `inv/rdoc` still serves external (MFR) documents purely from
`fds__getReportDocument`; regeneration never applies to them.
## Alternatives considered
- **Retry at finalisation instead of on read.** Would cover the store failure
but not an already-damaged row; regeneration on read heals both and matches
the behaviour `req/idoc` already had.
- **Regenerate drafts too, to mirror `req/idoc`'s render path.** `req/idoc`
*renders* a draft on the fly without storing it; persisting a draft through
`fds__setInvoiceFile` is what causes the permanent-draft defect above.
- **Return 404 and rely on the `_BackupAndClearInvoiceFile` recovery links.**
That proc is a manual-only maintenance tool, and the `inv/rdoc?create=1`
link it prints was never implemented on that endpoint.
@@ -0,0 +1,74 @@
---
status: Accepted
date: 2026-09-16
applyTo:
- "Fuchs/Services/FuchsWidgetService.cs"
- "Fuchs/Services/ProcessWebComService.cs"
- "Fuchs_Database/dbo/Functions/fds__fn_unpaidInvoices.sql"
- "Fuchs_Database/dbo/Functions/fds__fn_InvoicePaymentAmount*.sql"
- "Fuchs_Database/dbo/Functions/fds_wgd_*.sql"
- "Fuchs_Database/dbo/Functions/fds_wdg_*.sql"
- "Fuchs_Database/dbo/Stored Procedures/fds__logEmail.sql"
- "Fuchs_Database/Scripts/**"
supersededBy: ""
---
# 0026 — Dashboard widget SQL is set-based (no per-row scalar UDFs); `fds__emaillog.config` is a JSON contract
## Context
Two dashboard widgets were failing in the DB log (`fds__debuglog`, 2026-09-16):
1. **`wdg_invoices_overdue_sum`** timed out after the widget command timeout of 90 s. Its SQL called
`[dbo].[fds__fn_InvoicePaymentAmount](Id)` once per row of `fds__fn_unpaidInvoices()`. That scalar
UDF declares a table variable, so SQL Server 2019 cannot inline it (`sys.sql_modules.is_inlineable = 0`)
and re-executes the multi-statement TVF `fds__fn_bankingtransactions` — a full pass over
~11 k banking rows plus a `STRING_SPLIT` cross-apply over the settings heap — for **every** invoice:
≈0.5 s × 187 unpaid invoices ≈ 100 s measured. `wdg_unbez_akonto` had the same pattern (≈5 s,
hidden only by `daycache = 1`). The same total computed set-based (one TVF pass, `GROUP BY InvId`,
joined to the invoices) takes < 0.5 s and returns the identical figure.
2. **`wgd_lastEmails`** raised *JSON text is not properly formatted*. `fds_wgd_lastEmails` parses
`fds__emaillog.config` with `OPENJSON`/`JSON_QUERY`, but `ProcessWebComService` — the replacement
for the legacy mailer — wrote `config = ''` on every path. One such row was enough to error the
whole widget, and the audit trail had lost recipient/subject/attachment names for every email sent
through the new service.
Widget definitions are **data** (`fis_widgets.sql`), not schema, so there is no compile-time check
that a widget's SQL is cheap or that the columns it parses exist.
## Decision
- **Widget SQL and the functions backing it are set-based.** Per-invoice payment totals come from the
inline TVF `[dbo].[fds__fn_InvoicePaymentAmounts]()` (one banking pass, grouped by `InvId`) or from
the `[PaymentAmount]`/`[OpenAmount]` columns `fds__fn_unpaidInvoices()` now exposes. The scalar
`fds__fn_InvoicePaymentAmount(_full)` remains for single-invoice callers (invoice/reminder
preparation, per-row `paymentstatus = 'p'` cells in the invoice lists) but must not be called
per row from a widget or any other query that iterates a whole invoice set.
- **`fds__emaillog.config` is a JSON contract**, written by `ProcessWebComService.BuildAuditConfig`
in the legacy mailer's shape — `{subject, from, to[], cc[], bcc[], attachments[]}` with addresses
as `"Name" <email>` and attachments as file names only (never the body, never attachment bytes) —
on **every** audit path (success, API error, exception, invalid address, service disabled). SQL
consumers of the column must still tolerate non-JSON text (`IIF(ISJSON(x)=1, x, N'{}')` before
parsing), because `config` is `NOT NULL` and older rows/other writers may hold `''`.
- **Widget-data migrations live in `Fuchs_Database/Scripts/`** as idempotent, dated T-SQL files
(`<None Include>` in the sqlproj, not part of the schema build) and are run once per environment
after the schema deploy they depend on.
## Consequences
- `wdg_invoices_overdue_sum` 101.9 s → 0.45 s, `wdg_unbez_akonto` 4.7 s → 0.4 s, `wgd_lastEmails`
error → 15 ms (dev, 2026-09-16). The widget command timeout stays at 90 s; it is a guard, not a
budget.
- Adding a scalar UDF call inside a widget's `SELECT`/`WHERE` over `fds__invoices` or the
`fds__fn_unpaidInvoices()` set is a regression of this decision. Prefer exposing a set-based column
on the TVF, as done for `PaymentAmount`.
- `ProcessWebComServiceTests` assert the config contract on every audit path; changing the shape
requires updating `fds_wgd_lastEmails` in the same change.
- The `Scripts/2026-09-16_fis_widgets_set_based_payment_amounts.sql` migration must be run on
Production after the schema deploy (it is a no-op once applied).
## Alternatives considered
- **Raise the widget `CommandTimeout`** — masks an O(N) re-scan; the cost grows with every unpaid
invoice and every banking upload, and it would still block a dashboard load for minutes.
- **Convert `fds__fn_InvoicePaymentAmount` into an inlineable scalar UDF** — impossible while it
needs a TVP (`fds__tt__idlist_vchar`) to call the banking TVF; rewriting the banking TVF as an
inline TVF is a larger change to a function shared with the banking-assignment UI.
- **Fix the bad `fds__emaillog` row and leave the widget function strict** — the next `''` row (any
writer, any environment) would take the widget down again; tolerance belongs in the reader.
@@ -0,0 +1,114 @@
---
status: Accepted
date: 2026-09-17
applyTo:
- "Fuchs/Controllers/IntranetController.Admin.cs"
- "Fuchs/Services/IInvoiceFileBackupService.cs"
- "Fuchs/Services/InvoiceFileBackupService.cs"
- "Fuchs/Services/IBlobStorageService.cs"
- "Fuchs/Services/AzureBlobStorageService.cs"
- "Fuchs_Database/dbo/Stored Procedures/fds__admin_backupAndClearInvoiceFile.sql"
- "Fuchs_Database/dbo/Tables/fds__invoice_file_backups.sql"
supersededBy: ""
---
# 0027 — Admin invoice-file backup-and-clear action
## Context
Support occasionally needs to force a finalised invoice's PDF to be regenerated
(e.g. it was rendered with wrong data before a fix, or is otherwise corrupt).
Since [0025](0025-invoice-document-regenerate-on-miss.md), clearing
`fds__invoices.[file]` on a finalised, non-external invoice is enough to make
the next `req/idoc`/`inv/rdoc` read regenerate and re-persist it.
A manual maintenance proc for this already exists,
`[dbo].[_BackupAndClearInvoiceFile]`, but its own header comment states it is
"Manual-only maintenance utility. Never called from application code", and
0025's "Alternatives considered" explicitly rejected relying on it from the
app, partly because it leaves no application log entry. We still want this
capability reachable from the app (a hidden, admin-gated action) rather than
requiring direct SSMS access, without contradicting that standing decision.
Additionally, `IBlobStorageService` archives invoice PDFs to Azure Blob
Storage under a fixed per-invoice blob name (`{invoiceId}/{fileName}`) and
always uploads with `overwrite: true`. If a cleared invoice's document is
later regenerated (0025), `StoreInvoiceDocumentFileAsync` re-uploads under
that same blob name — silently overwriting the very copy this action is
meant to preserve as a backup, unless it is moved first.
## Decision
- A **new** proc, `[dbo].[fds__admin_backupAndClearInvoiceFile]`, is added
rather than calling `_BackupAndClearInvoiceFile`. It copies the invoice's
current `[file]` bytes into a new table, `[dbo].[fds__invoice_file_backups]`
(one row per backup, not a timestamp-named ad-hoc table per call), then
clears `[file]` in the same transaction (`XACT_ABORT` + TRY/CATCH with
rollback and re-raise, so a clear can never commit without its backup row).
It logs through the normal
`fds__admin_activity` insert (matching `fds__setInvoiceFile`'s pattern) and
every call is also logged through the app's `ILogger`/DB-log-sink pipeline
(ADR 0024) — unlike the manual proc, this path is fully auditable.
`_BackupAndClearInvoiceFile` is left completely untouched as the manual
break-glass tool for cases the app can't reach (e.g. a corrupt row the
admin UI can't load).
- The action applies 0025's regeneration precondition **before** touching
anything: only a **finalised, non-external** invoice qualifies. Both the
service (`IsDraft`/`IsExternal`) and the proc (`[DateFinalized] IS NOT NULL
AND ISNULL([IsExternal],0) = 0`) enforce it. Clearing a draft's `[file]`
would let the next read persist a draft render through
`fds__setInvoiceFile` (`WHERE [file] IS NULL`), which then blocks the real
final PDF forever — the exact defect 0025 calls load-bearing; an external
(MFR) document has no template and would just become a permanent 404.
- `IBlobStorageService` gains `CopyInvoicePdfToBackupAsync`: a
**server-side copy** (`StartCopyFromUriAsync`, bytes never pass through
the app) of the existing blob to a timestamped backup name in the same
container — the original blob is **kept**, not renamed. A private source in
the same storage account is authorised by the request's own Shared Key
credential, so no SAS is generated. The copy runs *before* the DB clear,
so the archived original is safe before any regenerate-on-read (ADR 0025)
could overwrite the original name; because the original stays in place, a
DB error or no-op afterwards leaves the canonical archive exactly as it
was (with a harmless spare copy). Keeping the original also removes the
half-state a copy-then-delete would have ("copied but couldn't delete").
Best-effort: a copy failure is logged and counted
(`fuchs.blobstorage.backupcopies.failed`), the DB backup row stays the
authoritative copy, and the DB step still runs.
- The action is exposed as a `Do_Process_Admin` case,
`admin/backupclearinvoicefile/<invoiceId>`, gated by the existing
`fds_sys > 4` check and restricted to `POST` (a GET must never trigger it,
since GETs can be issued by crawlers/prefetchers without user intent). It
is not part of any regular workflow UI; its only frontend is a card on the
admin-only "Specialties" page (`/specialties`, see
`Docs/Concepts/admin-specialties.md`).
## Consequences
- Two invoice-file "backup and clear" code paths now exist for different
audiences: `_BackupAndClearInvoiceFile` (manual, SSMS, no app log) and
`fds__admin_backupAndClearInvoiceFile` (app-triggered, fully logged). Keep
them separate — do not merge them, and do not point the manual proc's
printed recovery links at the new admin action.
- `fds__invoice_file_backups` accumulates one row per backup with no
automatic retention/cleanup; if this ever becomes a storage concern, that
is a separate decision.
- Blob backup-copy is best-effort: if it fails (or storage is
disabled/unconfigured), the invoice's DB row is still backed up and
cleared — the archived blob is simply left as-is (and will be overwritten
by the next regeneration). The DB backup table is the only *guaranteed*
copy this action makes.
- The blob container accumulates one `backup_<timestamp>_…` copy per action
next to the current blob; like `fds__invoice_file_backups`, no retention
is applied.
## Alternatives considered
- **Call `_BackupAndClearInvoiceFile` directly from the app.** Rejected: its
own header comment and ADR 0025 both state it must never be
application-triggered, and it produces no application log entry — using it
here would need updating that comment and (since decisions are immutable)
superseding 0025's "Alternatives considered" note, for no real benefit over
a small dedicated proc.
- **Skip the blob rename and let the next regeneration overwrite the archived
blob.** Rejected: the user asked for the blob copy to also survive as a
backup, and a silent overwrite on next read would defeat that.
@@ -0,0 +1,91 @@
---
status: Accepted
date: 2026-09-17
applyTo:
- "Fuchs/Services/IComService.cs"
- "Fuchs/Services/ProcessWebComService.cs"
- "Fuchs/Services/ProcessWebComSettings.cs"
- "Fuchs/Services/MailerAttachmentPolicy.cs"
supersededBy: ""
---
# 0028 — Mailer push_com: multipart attachments, count confirmation, contract pre-validation
## Context
The ProcessWeb Mailer API (`push_com`) was extended to accept attachments.
Its contract — https://processweb.de/api/mailer/fc — specifies
`multipart/form-data` with the JSON envelope in an unnamed form field and one
file part per attachment, an optional `attachmentCount` safety check, and a
200 response reporting `attachmentsReceived`/`attachmentsProcessed`.
`ProcessWebComService` predated that: it base64-inlined attachments as
`communication.attachments[]` in a JSON body. That property is not part of
the contract, so the Mailer accepted the message and dropped the files — a
200 for an invoice mail whose PDF never arrived, with no error anywhere. The
client also fabricated the audit `guid` (`Guid.NewGuid()`), treated a 409
(idempotency duplicate) as a generic API error, and called the legacy
`/mailer/fc?fn=verify` and `api.processweb.de` paths.
## Decision
1. **Request shape follows the contract exactly.** No attachments →
`application/json` envelope. With attachments → `multipart/form-data`:
envelope in an unnamed field (`name=""`, set via an explicit
`Content-Disposition` because `MultipartFormDataContent.Add` rejects an
empty name), then one file part per attachment with real bytes. Field
names as in the contract (`comType`, not `comtype`). Base URL
`https://processweb.de`, paths `/api/mailer/push_com` and
`/api/mailer/verify`.
2. **`attachmentCount` is always sent** so the Mailer itself rejects a
request whose file parts don't match.
3. **A 200 is not success when attachments were sent unless
`attachmentsProcessed` equals the number sent.** Anything else — fewer
processed, or a body that isn't the contract JSON — is reported to the
caller as a failed send (`false`, reason `attachments-mismatch`, counts
in the audit log). Callers gate "mark as sent" on that boolean, so an
invoice whose PDF was not processed is never flagged as sent.
4. **Contract limits are pre-validated client-side** (`MailerAttachmentPolicy`:
count, per-file and per-message size, empty file, missing/forbidden
extension) with the contract's defaults configurable under
`Fuchs:Mailer:Attachments`. A violation fails before any bytes are sent,
with the exact rule in the audit log. Empty files are a violation, not
silently skipped as before.
5. **`comStackID` is the audit key**, falling back to the `Message-Id`
header; a local id only when the Mailer returned neither.
6. **409 is a distinct failure** (`duplicate`): the Mailer already holds a
message under this `reference` and did not queue this one.
## Consequences
- `IComService.SendEmailAsync` returning `true` now means "accepted *and*
every attachment confirmed processed" — strictly stronger than before.
Existing callers (`req/sconf`, reminders, admin test mail) need no change;
they already treat `false` as not-sent.
- A caller passing a 0-byte attachment now gets `false` instead of a mail
without the file. That is intended: an empty PDF is an upstream failure
that should surface, not be hidden by a successful-looking send.
- The forbidden-extension list in the client is the contract's quoted subset;
the server's longer list stays authoritative and still answers 400 for the
rest.
- The concept doc `Docs/Concepts/processweb-mailer-interface.md` describes
the current client behaviour and must be kept in sync when the contract
evolves (it is declared backward compatible, so additions rather than
breaks are expected).
- The verify response is still parsed as `{ ok, account, serverid }`; if the
`/api/mailer/verify` endpoint's body differs from the legacy `fc?fn=verify`
one, only `VerifyCredentialsAsync`'s DTO needs adjusting.
## Alternatives considered
- **Keep inline base64 and ask the Mailer to accept it too.** Rejected: the
contract is the Mailer's; two accepted shapes would just be a second
thing to keep compatible, and base64 inflates every PDF by a third.
- **Treat 200 as success regardless of `attachmentsProcessed`.** Rejected —
that is exactly the silent-drop failure mode this change exists to close.
- **Treat 409 as success ("already sent").** Rejected: a resend under the
same reference would then report success without anything being queued;
the caller must see it and decide (e.g. use a new reference).
- **Skip client-side validation and rely on the server's 400.** Rejected:
the 400 body is an opaque string in the audit log; a named rule is what
support needs, and it costs nothing.
@@ -0,0 +1,101 @@
---
status: Accepted
date: 2026-09-17
applyTo:
- "Fuchs/Services/ProcessWebComService.cs"
- "Fuchs/Services/FuchsMailerClient.cs"
- "Fuchs/Services/MailerAttachmentPolicy.cs"
- "Fuchs/Services/ProcessWebComSettings.cs"
- "Fuchs/Program.cs"
supersededBy: ""
---
# 0029 — Outbound mail goes through the OCORE_web PWMailer client
## Context
Right after [0028](0028-mailer-push-com-multipart-and-attachment-confirmation.md)
re-implemented the Mailer API's `push_com` contract by hand in
`ProcessWebComService`, OCORE_web gained a complete typed client for that
API (`OCORE_web/comm/pwmailer/`, `IPWMailerClientService`): every operation,
the JSON/multipart request shapes, `attachmentCount`, typed receipts,
Basic auth with Key Vault/encrypted token resolution, `x-pcw-excd` error
headers, retries and timeouts — declared to be kept in sync with the
server-side controllers. Keeping a second implementation of the same
contract in Fuchs would mean two things to update on every API change.
The client validated a message's required fields and empty/extension-less
attachments, but had no hook for application rules (size limits, a
file-type deny-list) — `PWMailerMessage` is sealed and `PushAsync` called
`Validate` directly.
## Decision
1. **`ProcessWebComService` is an adapter over `IPWMailerClientService`.**
It keeps only what is Fuchs-specific: recipient validation and the
`Fuchs:Email:OverrideRecipient` safety net, the signature, building the
`PWMailerMessage`, interpreting the `PWMailerResult`, the `fds__emaillog`
audit row and telemetry. All HTTP transport lives in the OCORE client;
the hand-rolled `PostPushComAsync`/MIME guessing from 0028 is deleted.
2. **Pre-validation is an overridable hook in the client.** OCORE_web's
`PWMailerClientService` gains `protected virtual ValidateMessage(PWMailerMessage)`
(default: `PWMailerMessage.Validate`), called by `PushAsync` and
`PushBulkAsync` before anything is sent. Fuchs overrides it in
`FuchsMailerClient` to add the contract's attachment limits
(`MailerAttachmentPolicy`: count, per-file/per-message size, forbidden
extensions, configurable under `Fuchs:Mailer:Attachments`). A refusal is a
`PWMailerResult` with `StatusCode 0` / `Attempts 0`, which the adapter
reports as reason `validation` with one audit line per rule.
3. **One configuration section.** `Fuchs:Mailer` (`ProcessWebComSettings`)
remains the source; `Program.cs` maps it onto `PWMailerClientOptions`
(`BaseUrl + /api/mailer`, `AccountId → Account`, `Token`, `ServerId`) and
sets `TokenSecretName = "Fuchs--Mailer--Token"`, i.e. the Key Vault secret
OCORE secret management already maps into `Fuchs:Mailer:Token`. No second
`PWMailer` section, no second secret.
4. **0028's behavioural rules stand**, with one correction: multipart with
`attachmentCount`, a 200 counts as success only when
`attachmentsProcessed` equals the number sent, `comStackID` is the audit
key. **409 is now a success, not a `duplicate` failure** (this replaces
0028's rule 6): the client documents 409 as "duplicate of a still-pending
entry with the same server/reference/content — nothing was enqueued, the
earlier entry will be sent". The mail is therefore on its way; the resend
paths (`req/resend`, reminders) reuse `inv_<Id>` as the reference, and a
double click must not surface as a send error. The deduplication is
recorded in the audit row. They are enforced partly by the client (shape,
count) and partly by the adapter (processed check, audit, 409 mapping).
5. Two small OCORE_web fixes made in the same change: `PWMailerMessage.Validate`
no longer demands a subject for `comType "s"` (SMS), and a call refused
by validation reports `Attempts = 0` as its documentation already stated.
## Consequences
- Fuchs no longer asserts the wire format in its own tests; that lives in
`OCORE_web/OCORE_webTests/comm/PWMailerClientServiceTests.cs`. Fuchs tests
assert the `PWMailerMessage` handed to the client and the interpretation of
each `PWMailerResult` shape, plus `FuchsMailerClient`'s refusals.
- OCORE_web is a submodule owned by another repository: the hook, the SMS
rule and the `Attempts` fix are a commit there and a pointer bump here.
- The client is a singleton with a cached token; after a token rotation
`IPWMailerClientService.ResetToken()` (on the concrete type) is the way to
pick it up without a restart — not wired to any admin action yet.
- Empty attachments are still refused (now by the client's base rule), so
an upstream render failure never produces a mail without its PDF. The
flip side: a caller attaching an *optional* file must skip an empty byte
array itself — the reminder's invoice copy (`IntranetController.Reminder.cs`)
does, otherwise a never-stored invoice file would fail the whole reminder
instead of sending it without the copy.
- The client's `getCom`/`getComStatus`/`abortCom` and inbox operations are
available to Fuchs for free (e.g. a delivery-status column keyed by the
audited `comStackID`) — not used yet.
## Alternatives considered
- **Keep the 0028 hand-rolled transport.** Rejected: duplicate contract
implementation, and the OCORE client is the one declared to track the
server.
- **Pre-validate in the adapter only (before `PushAsync`).** Rejected: the
rules belong with the client's own validation so every OCORE consumer can
apply them the same way, and the user asked for an overridable hook.
- **Bind a separate `PWMailer` config section.** Rejected: `Fuchs:Mailer` is
already Key Vault-managed and read by the Admin status view; mapping in
code avoids a second token secret and drift between two sections.
@@ -0,0 +1,69 @@
---
status: Accepted
date: 2026-09-17
applyTo:
- "Fuchs_Database/dbo/Stored Procedures/fds__admin_backupAndClearInvoiceFile.sql"
- "Fuchs_Database/dbo/Tables/fds__invoice_file_backups.sql"
supersededBy: ""
---
# 0030 — Invoice-file backup carries a JSON snapshot of the invoice data
## Context
ADR 0027 introduced `[dbo].[fds__invoice_file_backups]`: one row per
admin backup-and-clear action holding the invoice's `[file]` bytes before
they are cleared for regeneration (ADR 0025). The backup held only the
rendered PDF. The data it was rendered from — the `fds__invoices` row, its
`fds__invoice_items` rows, its `fds__invoice_details` row and its
`fds__invoice_servicerequests` link rows — lives on in
the live tables and keeps changing (payment status, modification stamps,
later edits). A backed-up file could therefore no longer be matched against
the data state it was produced from, which is exactly the question the
backup exists to answer when a regenerated document differs from the
original.
## Decision
- Each backup row additionally stores four JSON snapshots, written in the
same statement and transaction as the file copy:
- `InvoiceJson` — the `fds__invoices` row (`FOR JSON PATH,
WITHOUT_ARRAY_WRAPPER`), **without** `[file]`, which is already stored as
bytes in the row and would otherwise be base64-duplicated;
- `InvoiceItemsJson` — all `fds__invoice_items` rows for the invoice as a
JSON array, ordered by `SortOrder`, `Position`, `Id`;
- `InvoiceDetailsJson` — the `fds__invoice_details` row, or `NULL` if the
invoice has none;
- `InvoiceServiceRequestsJson` — all `fds__invoice_servicerequests` rows
for the invoice as a JSON array, ordered by `SortOrder`, `Id`. Not part
of the rendered document, but part of the invoice's data set.
- The snapshots are JSON columns on the **same** backup row rather than
four mirror tables: one row is one consistent set (file + data), the
atomicity guarantee of ADR 0027 covers it without extra keys, and adding a
column to an invoice table does not require touching the backup schema.
The item/detail/service-request snapshots use `SELECT *`, so new columns appear
automatically; the invoice snapshot lists its columns explicitly only to
exclude `[file]` — a new `fds__invoices` column must be added to that
list.
- Restore is manual (`OPENJSON` in SSMS); no app-side reader is added. The
snapshots are forensic evidence, not a rollback mechanism.
## Consequences
- Backup rows grow by the size of the invoice's data (small next to the
PDF). No retention policy is added — same standing as ADR 0027.
- `INCLUDE_NULL_VALUES` is used so a `NULL` in the snapshot is
distinguishable from a column that did not exist at backup time.
- Adding a column to `fds__invoices` requires updating the explicit column
list in the proc; `fds__invoice_items` / `fds__invoice_details` /
`fds__invoice_servicerequests` need no change.
## Alternatives considered
- **Mirror tables** (`fds__invoice_backups`, `fds__invoice_items_backups`,
…) keyed by backup `uid`: typed and queryable, but four schemas that
drift with every invoice-table change and four inserts to keep atomic.
Rejected for maintenance cost; the snapshots are read rarely and by hand.
- **Snapshot only the invoice header**: the items are what the PDF body is
rendered from; leaving them out would not answer "what did this file
contain and why".
@@ -0,0 +1,76 @@
---
status: Accepted
date: 2026-09-09
applyTo:
- "OCORE/OCORE/Secrets/**"
- "OCORE_web/OCORE_web/Secrets/**"
- "Fuchs/Program.cs"
- "Fuchs/appsettings*.json"
supersededBy: ""
---
# 0031 — Shared Key Vault + DPAPI secret management via OCORE/OCORE_web
## Context
Fuchs is one of several applications (web apps, console apps, Windows
services, worker services) running on the same Windows server. Secrets
(connection strings, API keys, mailer tokens, etc.) previously lived in
plain text in `appsettings.json`/`web.config`/environment variables. A
single design needed to work identically across all host types — not just
ASP.NET Core — while keeping secrets out of source control and available
even when the Key Vault is briefly unreachable.
## Decision
- **One shared Azure Key Vault** (`pcwkeys`) for all applications on the
server, rather than a per-app vault. Access control happens at the
secret level (RBAC), not at the vault level.
- **`{appname}--` prefix namespacing** on every secret name (e.g.
`fuchs--ConnectionStrings--ocms-ConnectionString`) prevents collisions
between apps sharing the vault. Naming/encoding rules are recorded in
`.github/copilot-instructions.md` under "Azure Key Vault — Secret
Naming".
- **Core logic lives in `OCORE`** (`OCORE/Secrets/Models/AppSecretsOptions.cs`,
`Cache/DpapiSecretsCache.cs`, `Cache/DpapiCacheConfigurationSource.cs`,
`Sync/KeyVaultSyncService.cs`, `Extensions/SecretManagementExtensions.cs`,
`Extensions/PrefixKeyVaultSecretManager.cs`, `Extensions/SecretsOnDemandLoader.cs`)
because it has no web dependency and must also work for console apps,
Windows services, worker services, and WinForms/WPF. `OCORE_web` adds a
single thin file (`Secrets/SecretManagementWebExtensions.cs`) exposing
`WebApplicationBuilder.AddSecretManagement()` for ASP.NET Core hosts;
it calls back into `OCORE`'s shared logic rather than duplicating it.
- **Local DPAPI cache as offline fallback**: secrets synced from Key Vault
are also encrypted to `secrets.cache` using Windows DPAPI with
`DataProtectionScope.LocalMachine` plus app-specific entropy derived
from `AppName`, so one app on the server cannot decrypt another app's
cache file, and the app can still start if Key Vault is briefly
unreachable.
- **One shared server-level Managed Identity** (via `DefaultAzureCredential`)
for all apps, rather than 15 separate identities — access is scoped by
the `{appname}--` secret prefix via Key Vault RBAC, not by identity.
## Consequences
- Fuchs calls `builder.AddSecretManagement()` (`Fuchs/Program.cs`) before
`FuchsOcmsIntranet.Initialize(configuration)` — secrets must be resolved
before any singleton reads `IConfiguration`.
- New secrets must be added to Key Vault under the `fuchs--` prefix *and*
registered in `ManagedSecretKeys` in `Fuchs/appsettings.json` (see
`.github/copilot-instructions.md`), or `KeyVaultSyncService` will never
pick them up.
- A secret whose name is only known at runtime (not a fixed config key)
bypasses `ManagedSecretKeys`/`KeyVaultSyncService` entirely and is
fetched directly via `OCORE.Secrets.Extensions.SecretsOnDemandLoader`
instead.
- Any other application added to the shared server follows the same
pattern: reference `OCORE_web` (web) or `OCORE` (non-web), set a unique
`AppName`, and grant its prefix `Get`/`List` access in the shared vault.
- Detailed wiring/encoding reference (kept current independently of this
ADR) lives in `OCORE_web/.github_instructions/secretsmanagement.instructions.md`.
## Alternatives considered
- **Per-app Key Vaults** — rejected: 15x the management overhead for no
isolation benefit over prefix namespacing + secret-level RBAC.
- **Per-app Managed Identities** — rejected: requires provisioning and
maintaining 15 separate identities; a shared identity with per-secret
RBAC achieves the same isolation.
- **Environment variables only, no local cache** — rejected: no
offline/startup resilience if Key Vault is briefly unreachable.
@@ -0,0 +1,68 @@
---
status: Accepted
date: 2026-09-30
applyTo:
- "Fuchs_Database/dbo/Functions/fds__fn_invoiceBackdate.sql"
- "Fuchs_Database/dbo/Stored Procedures/fds__setInvoiceFinal.sql"
- "Fuchs/code/InvoiceDates.cs"
- "Fuchs/Services/ERechnungMapper.cs"
- "Fuchs/Services/InvoiceService.cs"
- "Fuchs/Controllers/IntranetController.InvoiceDraft.cs"
- "Fuchs/Controllers/IntranetController.Requests.cs"
- "Fuchs/js/intranet/modules/fis.inv_shared.js"
supersededBy: ""
---
# 0032 — Invoices may be backdated to the last working day of the previous month (admins only)
## Context
Work done at the end of a month is often invoiced in the first days of the next month. The customer
wants those invoices dated to the previous month. The invoice date was always the moment of
finalisation (`fds__setInvoiceFinal`: `DateFinalized = GETUTCDATE()`), and the invoice number is
drawn from the year of that date (`fds__newInvoiceId(Year(...))`).
Two invoice dates also existed side by side: the PDF printed `DateCreated` (draft creation) as
"Rechnungsdatum", while the embedded ZUGFeRD/XRechnung issue date (BT-2) used `DateFinalized` — a
visible date and an e-invoice date that could disagree.
## Decision
- **One invoice date.** PDF "Rechnungsdatum" and e-invoice issue date both come from
`InvoiceDates.IssueDate`: `DateFinalized` (converted from the stored UTC to the German calendar
day), falling back to `DateCreated` for drafts.
- **Backdating option.** When finalising, a user with `fds_inv > 2` may date the invoice to the
**last working day (Mon–Fri) of the previous month** — a fixed date, not a free choice. Public
holidays are not excluded (possible later extension).
- **Only while no invoice exists after that date.** Invoice numbers must ascend with invoice dates,
so the option is offered and accepted only while no invoice has been finalized after the backdate
day (a 2 h margin covers the UTC storage).
- **Single source of truth:** `[dbo].[fds__fn_invoiceBackdate](@authuser)` returns the allowed date or
NULL (authorisation + eligibility). `inv/dpreview` returns it so the preview dialog shows the
per-invoice checkbox; `req/sconf` passes `@backdate = 1`, and `fds__setInvoiceFinal` re-evaluates
the function and `THROW`s (60001) when it is no longer allowed — nothing is finalised then.
- **Serialized finalisation.** `fds__setInvoiceFinal` runs check + numbering `UPDATE` in one
transaction under an exclusive, transaction-owned app lock (`sp_getapplock`, resource
`fds__setInvoiceFinal`) taken by every finalisation, so a concurrent normal finalisation cannot
slip between the eligibility check and the backdated write and break the date/number order.
- `req/sconf` maps only SQL error 60001 to the "no longer possible, create without backdating"
message. A 0-row result (invoice already final/sent, e.g. a double submit) is reported as
"already finalised" — never as a backdate rejection, which would invite a duplicate invoice.
- A backdated invoice is stored with `DateFinalized = <day> 12:00` (UTC, stays on the same German
day), its number comes from that day's year, and `DueDate` is recomputed from the payment term
anchored on that day.
## Consequences
- The PDF of every newly finalised invoice now shows the finalisation date instead of the draft
creation date (intended: it now matches the e-invoice and the number sequence).
- In January the option yields a December date and therefore a number from the previous year's range.
- Lists, reports, DATEV and reminders follow the same invoice date — see ADR 0035.
- Telemetry: `fuchs.invoices.backdated` (outcome ok / rejected / already_final / error); successful
backdating logs EventId `InvoiceBackdated`.
## Alternatives considered
- **Free date picker for admins** — rejected: breaks the date/number order too easily; the need is
exactly "previous month's last working day".
- **Global admin switch "backdate everything until the first invoice of the month"** — rejected by
the customer in favour of an explicit per-invoice checkbox.
@@ -0,0 +1,52 @@
---
status: Accepted
date: 2026-09-30
applyTo:
- "Fuchs_Database/dbo/Tables/mfr__*.sql"
- "Fuchs_Database/dbo/User Defined Types/mfr__tt__*.sql"
- "Fuchs_Database/dbo/Stored Procedures/mfr__updt__*.sql"
- "Fuchs_DataService/FdsMfrClient.cs"
- "Fuchs_DataService/FdsColumnWidths.cs"
supersededBy: ""
---
# 0033 — mfr free-text columns are NVARCHAR(MAX); table, table type and copies stay aligned
## Context
mfr publishes no length limits for its string properties (`MFR_RESTClient/Docs/mfr_interface_description.md`).
`[mfr__serviceobjects].[Note]` and `[mfr__companies].[Note]` were `NVARCHAR(255)`, as were their
merge types `mfr__tt__serviceobjects` / `mfr__tt__companies`. From 2026-09-25, users typed longer
notes in mfr (ServiceObject up to 753 chars, Company up to 723). The `mfr__updt__*` merge proc copies
the staging table into the table type, so SQL Server rejected every batch holding such a row
(msg 8152, which names no column). That included every Company/Contact/ServiceRequest sync that
`$expand`s the row. The result was about 1000 errors in five days, plus rows the short sync never
re-fetched, because it only reads rows newer than the local `DateModified` watermark. Merge failures
were silent until 8bcec40 began logging them, so earlier occurrences went unnoticed.
## Decision
- Free-text mfr properties (`Note`, `Description`, `Comment`, …) are stored as `NVARCHAR(MAX)`.
Bounded widths are kept only for values mfr structurally keeps short (ids, codes, names), and
those are revisited when data proves otherwise.
- A width change is made in the target table, its `mfr__tt__*` type **and** every table variable
or type that copies the row with `SELECT *` (e.g. `@company` in `fds__getRequest_details`,
`fds__prepInvoice`, `fds__prepStorno_recreate`), all in the same change. Otherwise the truncation
just moves to the next copy.
- Truncating at the mapping layer was rejected. The data service does **not** truncate
silently. Instead, when a merge fails with 8152/2628, `FdsColumnWidths` compares the staged
batch against the narrower of the table and type widths. The error log then names column, longest
length, limit and an entity Id (`over-long columns: Note: 753 > 255 chars (1 row, e.g. Id=…)`).
This lookup runs only on the failure path.
## Consequences
- Changing a `mfr__tt__*` type means DROP + CREATE (types cannot be ALTERed). The procs that
DECLARE the type must be dropped first and recreated. See
`Fuchs_Database/Scripts/2026-09-30_mfr_truncation_fix.sql` for the pattern, including grants.
- After fixing a rejected merge, queue the affected Ids in `fds__mfr_updaterequests`. The short
sync will not revisit them, and the nightly full reset is disabled in `fds__getUpdateableTables`.
- `NVARCHAR(MAX)` columns cannot be index key columns. None of the widened columns is indexed.
## Alternatives considered
- **Truncate to the column width in `SetRowValue`**: this would keep syncing, but it loses user text
silently. For keys such as `ExternalId` (DATEV customer number) it would corrupt data. Rejected.
- **Widen to a larger fixed size (e.g. 1000, like `mfr__contacts.Note`)**: this just moves the
cliff. mfr enforces no limit. Rejected for free text.
@@ -0,0 +1,64 @@
---
status: Accepted
date: 2026-09-30
applyTo:
- "Fuchs_Database/dbo/Functions/fds__fn_IntermediateIsAllocatedToOther.sql"
- "Fuchs_Database/dbo/Stored Procedures/fds__setInvoiceFinal.sql"
- "Fuchs_Database/dbo/Stored Procedures/fds__prepInvoice.sql"
- "Fuchs_Database/dbo/Stored Procedures/fds__prepStorno_recreate.sql"
- "Fuchs_Database/dbo/Stored Procedures/fds__createStorno_*.sql"
- "Fuchs_Database/Scripts/2026-09-30_backfill_intermediate_invoice_allocations.sql"
supersededBy: ""
---
# 0034 — An Abschlagsrechnung credit follows the live invoice
## Context
A Schlussrechnung credits the Abschlagsrechnungen (intermediate invoices, `InvoiceType = 'i'`)
already paid for the same service requests via `IntInvoiceAllocation` lines. Finalising it records
the claim in `fds__invoice_details.AllocatedTo_InvId` of each credited intermediate so that
`fds__prepInvoice` does not offer the same Abschlagsrechnung again.
Until 2026-09-30 that claim was never actually written (the editor dropped `reference_InvId`, the
MERGE aborted on the NULL). The moment it is written, the question "what happens to the claim when
the crediting invoice is cancelled?" becomes real: a Storno (simple/copy) or a Storno + recreate
leaves `AllocatedTo_InvId` pointing at the cancelled invoice, and the next invoice for the same
service requests would silently bill the customer again for money already paid.
## Decision
- **A claim counts only while the crediting invoice is alive.** An `AllocatedTo_InvId` that points
at a cancelled invoice (`IsCanceled = 1`), at a non-existent invoice, or at the invoice that the
one being finalised replaces (`CanceledInvoice` line → `@Replaces`) is void: the Abschlagsrechnung
is offered again by `fds__prepInvoice` / `fds__prepStorno_recreate` and may be taken over by the
next finalisation. The single source of truth for "already credited elsewhere" is
`[dbo].[fds__fn_IntermediateIsAllocatedToOther]`; `fds__setInvoiceFinal`'s `existing` CTE is its
set-based twin and must apply the same rule.
- **The link is not cleared on cancellation.** Cancellation has several paths (storno simple/copy,
storno + recreate, the retired `fds__setInvoiceCancelled`); rather than releasing the link in each,
the readers ignore dead links. The last claimant therefore stays visible for auditing.
- **A Stornorechnung (`InvoiceType = 'c'`) never claims.** `fds__createStorno_copy` mirrors the
cancelled invoice's `IntInvoiceAllocation` lines (with their references) so the storno document
shows them, but finalising the storno must not move the allocation onto it.
- **Storno + recreate carries the references.** `fds__prepStorno_recreate` emits its credit lines
with the same type as `fds__prepInvoice` (`IntInvoiceAllocation`, not `PartialInvoice`) and the
credited invoice in `reference_InvId`, and its `CanceledInvoice` line references the replaced
invoice — otherwise the replacement neither takes over the credit nor records what it replaces.
## Consequences
- The recreate flow now produces `Replaces_InvId` / `DateCancelled` on the replaced invoice via the
regular `fds__setInvoiceFinal` bookkeeping (it did not before: no reference reached the proc).
- A second recreate (cancel the replacement, recreate again) keeps the credit because the claim on
the replaced invoice is treated as void during the replacement's finalisation.
- The one-off backfill script records the *earliest* live claimant; it is consistent with this rule.
- An intermediate that is itself cancelled is not offered (unchanged).
## Alternatives considered
- **Release `AllocatedTo_InvId` when the crediting invoice is cancelled** — rejected: four write
paths to keep in sync, and the audit trail of who last credited the Abschlagsrechnung is lost.
- **Keep `PartialInvoice` for recreated credit lines and teach `fds__setInvoiceFinal` both types** —
rejected: one line type per meaning; `PartialInvoice` remains the Abschlagsbetrag charge line of an
intermediate invoice.
@@ -0,0 +1,63 @@
---
status: Accepted
date: 2026-09-30
applyTo:
- "Fuchs_Database/dbo/Functions/fds__fn_invoiceDate.sql"
- "Fuchs_Database/dbo/Functions/fds__r_getBalanceThisMonth.sql"
- "Fuchs_Database/dbo/Stored Procedures/fds__getDatevExports.sql"
- "Fuchs_Database/dbo/Stored Procedures/fds__getInvoices_list*.sql"
- "Fuchs_Database/dbo/Stored Procedures/fds__getFDSDocument.sql"
- "Fuchs_Database/dbo/Stored Procedures/fds__getReportDocument.sql"
- "Fuchs_Database/dbo/Stored Procedures/fds__getRequest_details.sql"
- "Fuchs_Database/dbo/Stored Procedures/fds__r_*.sql"
- "Fuchs_Database/dbo/Stored Procedures/fds__*Reminder*.sql"
- "Fuchs/code/InvoiceDates.cs"
- "Fuchs/Services/ReminderService.cs"
- "Fuchs/Services/*DraftEditService.cs"
supersededBy: ""
---
# 0035 — The invoice date is `DateFinalized` everywhere
## Context
ADR 0032 made `DateFinalized` the invoice date for the PDF, the e-invoice (BT-2) and the number
sequence, and introduced backdating (which only moves `DateFinalized`). Everything else — invoice
lists, revenue reports and charts, the DATEV export, document lookups, the request details — still
dated and grouped invoices by `DateCreated`, the moment the *draft* was created. A backdated invoice
therefore showed September on the document and October in every list, report and in DATEV, and any
invoice drafted in one month and finalised in the next had the same split.
## Decision
- **One invoice date, everywhere:** the German calendar day of `DateFinalized`, falling back to
`DateCreated` only for drafts. Both columns are stored in UTC.
- **One implementation per side:** `[dbo].[fds__fn_invoiceDate](DateFinalized, DateCreated)` in SQL and
`InvoiceDates.IssueDate` in C#. No proc converts or picks the date itself.
- Applies to: the DATEV export (period filter, `Belegdatum`, `Belegfeld 2`), the invoice lists
(`fds__getInvoices_list`, `list2`, `list_vario`: period filter, `Belegdatum`, `DateOfCreation`,
ordering, overdue fallback), the revenue reports (`fds__r_*`: year/month filters and grouping,
`fds__r_getBalanceThisMonth`), document lookups (`fds__getFDSDocument`, `fds__getReportDocument`),
`fds__getRequest_details` (new column `InvoiceDate`, shown in the request's invoice list), and the
invoice date shown in reminders (`fds__prepReminder`, `fds__createReminder`, `fds__getReminder`).
- **Reminders use the same rule for their own date:** the reminder PDF's date is the reminder's
`DateFinalized` (German day), draft: `DateCreated`.
- **No transition safeguard.** The switch applies to all periods, past and future; already exported
DATEV periods are not reconciled (confirmed by the business owner, 2026-09-30).
- `DateCreated` keeps its meaning — the creation timestamp of the row — and is still returned where
callers read it as such. Draft previews stamp it with `DateTime.UtcNow` to match the stored column.
## Consequences
- Reports and exports for a past period can differ from what they showed before this change: an
invoice drafted in month M and finalised in M+1 now counts in M+1.
- Filters on the invoice date are not SARGable (they were not before either: `CAST(DateCreated as date)`).
Acceptable at the current table size; an indexed computed column is the follow-up if it matters.
- Any new list, report or export that shows or groups invoices by date must use
`fds__fn_invoiceDate` — never `DateCreated` or a raw `DateFinalized`.
## Alternatives considered
- **Keep `DateCreated` in lists/reports, `DateFinalized` only on documents** — rejected: the same
invoice would carry two dates, and backdating would never reach the books.
- **Transition safeguard for already exported DATEV periods** — rejected by the business owner.
@@ -0,0 +1,85 @@
---
status: Accepted
date: 2026-09-30
applyTo:
- "Fuchs_Database/**"
supersededBy: ""
---
# 0036 — The database project build is the T-SQL gate
## Context
A rewrite of `fds__getDatevExports` added a `JOIN @inv as i` next to `fds__invoices as iv`
and left an unqualified `[file_guid]` in the select list. SQL Server rejects that statement
with Msg 209 (ambiguous column name) — but only when it runs, and the working-tree SQL runs
nowhere: the schema lives in the `Fuchs_Database` SSDT project, the dev database only gets
what is explicitly published to it, and the xUnit suite cannot bind T-SQL. The defect reached
code review as the only check.
The SSDT build would have caught it: it reported the line as `SQL71502` ("unresolved reference
… or ambiguous"). Nobody saw that because
- the project did not build at all — the two `CREATE USER … FOR LOGIN` files referenced logins
that were not in the model, which SSDT treats as error `SQL71501`;
- the genuine warning sat among ~160 identical `SQL71502` warnings caused by references to
`sys.*`, `INFORMATION_SCHEMA`, `sp_executesql`, `sp_getapplock` (no `master` reference) and
to `ocms_fn_generatePassword`, an OCMS helper the project never contained;
- a warning does not fail anything.
The same sweep found that `ocms_fn_generatePassword` (and its dependency `ocms_fn_randbetween`)
is missing on the dev database as well, so every id-generating function (`fds__fn_invoice_id`,
`fds__fn_reminder_id`, `fis_fn_id`, …) fails there with Msg 4121.
## Decision
1. `FuchsDatabase.sqlproj` references the `master` system dacpac
(`$(DacPacRootPath)\…\SqlServer\170\SQLSchemas\master.dacpac`), so system objects bind.
2. Everything the SQL references is in the project. The OCMS helpers `ocms_fn_generatePassword`
and `ocms_fn_randbetween` are vendored copies of the OCMSsharp `ocms_mt_database` files.
`Security/Logins.sql` scripts the two application logins with a placeholder,
`MUST_CHANGE` password so the users bind; real passwords stay in Key Vault and SSDT never
compares passwords, so existing logins are untouched on publish.
3. `TreatTSqlWarningsAsErrors` is on. Suppressed: `71501` (dynamic SQL / cross-database
references) and `71558` (identifier differs only by case — harmless under the `CI` model
collation). Every other T-SQL warning, in particular `SQL71502`, fails the build.
4. Building the database project is part of the workflow whenever `Fuchs_Database/**` changes
(`msbuild Fuchs_Database/FuchsDatabase.sqlproj -p:Configuration=Debug`, from the Visual
Studio MSBuild — `dotnet build` cannot build the legacy SSDT project format). A red database
build is a failure like a red C# build.
5. The C# side of the contract is a test: `Fuchs.Tests/SqlContractTests` scans every
`[dbo].[…]` named in C# strings (`Fuchs`, `Fuchs_DataService`) and checks it against the
SSDT files — the object exists, every `EXECUTE` binds (no unknown names, no surplus
positional arguments, every parameter without a default supplied), every function call
passes the declared argument count, and every `mfr__updt__*` proc declares the
`@tblname, @referencetable, @tgtid` signature `FdsMfrClient` calls dynamically.
## Consequences
- An unqualified column that becomes ambiguous, a typo in a column name, or a call to a
function that is not in the project is a build error before review, not a Msg 209/4121 at
a customer.
- `SELECT *` inside `EXISTS (…)` over joined tables trips `SQL71502` in SSDT even though SQL
Server accepts it; write `SELECT 1` there (done for `fds__fn_IntermediateIsAllocatedToOther`
and `fis_admin_testGroupMembership`).
- New procedures that use `sys.*`/`sp_*` need no suppression any more. A new reference that is
genuinely outside the model (another database, dynamic SQL) is suppressed per statement or
by adding the object to the project — never by widening `SuppressTSqlWarnings` for `71502`.
- A proc signature change that breaks a C# caller fails `dotnet test`, not the first request.
Only literal SQL is covered: dynamically built names (`FuchsVisualization`'s report procs,
resolved from `sys.parameters` at runtime) and result-set column names are not.
- The build proves binding, not behaviour: publishing to the dev database (`SqlPackage
/Action:Publish` with `ExcludeObjectTypes=Logins;Users;Permissions;RoleMembership`, or the
schema compare) and exercising the code path remains the runtime check.
- The dev database needs the two `ocms_*` helpers published before any id-generating path
works there.
## Alternatives considered
- **Run the changed procedures against the dev database before review.** Needs a publish of
every working-tree change first, and does not cover code paths the reviewer does not think
to exercise; the compile-time check is free and total.
- **Suppress `SQL71502` and keep the build warning-free that way.** Removes exactly the signal
that catches this class of bug.
- **A C#-side test that parses the SQL for unqualified columns.** A parser-level heuristic
without a schema cannot decide ambiguity; the SSDT model can.
+52 -11
View File
@@ -97,7 +97,9 @@ payload; see `EVAL_live_invoice_editing.md` for the rationale.
it drives the EN 16931 eRechnung and composes the free-text `SendToAddress` for the it drives the EN 16931 eRechnung and composes the free-text `SendToAddress` for the
PDF (see [`Concepts/erechnung-output.md`](Concepts/erechnung-output.md)). The **service PDF (see [`Concepts/erechnung-output.md`](Concepts/erechnung-output.md)). The **service
date/period** (Leistungsdatum/-zeitraum) is likewise a structured German-date dialog date/period** (Leistungsdatum/-zeitraum) is likewise a structured German-date dialog
(`$inv.eProvisionPeriod`) — a single date or a from/to range — mapped to BT-72 / BG-14. (`$inv.eProvisionPeriod`) — a single date or a from/to range — mapped to BT-72 / BG-14
and printed in the PDF's admin block (`InvoiceService.ApplyProvisionPeriod`; legacy ISO
values are shown as German dates).
- **§13b reverse-charge** toggle (`$inv.sp13b`) — suppresses VAT lines/columns. - **§13b reverse-charge** toggle (`$inv.sp13b`) — suppresses VAT lines/columns.
- **Set-pricing display mode** (`$inv.ssetmode` / `setSetmode`) — `SetPrice` - **Set-pricing display mode** (`$inv.ssetmode` / `setSetmode`) — `SetPrice`
(default) / `SetOnly`; see `INVOICE_SET_PRICING.md`. Purely presentational (default) / `SetOnly`; see `INVOICE_SET_PRICING.md`. Purely presentational
@@ -116,7 +118,7 @@ vat }` plus set header/member tagging) that is posted to the server.
| User action | JS entry point | Endpoint | Controller handler | Effect | | User action | JS entry point | Endpoint | Controller handler | Effect |
|---|---|---|---|---| |---|---|---|---|---|
| First save without preview | `$inv.ssave` | `req/save` | `Do_Process_Requests` case `save` | `RegisterInvoiceAsync(change: id present)` — creates or updates the draft row, **no PDF rendered** | | First save without preview | `$inv.ssave` | `req/save` | `Do_Process_Requests` case `save` | `RegisterInvoiceAsync(change: id present)` — creates or updates the draft row, **no PDF rendered** |
| Preview a **new** invoice | `$inv.sprev(false)` | `req/sprep` | case `sprep` | Registers draft, then `GenerateInvoicePdf` → renders preview images | | Preview a **new** invoice | `$inv.sprev(false)` | `req/sprep` | case `sprep` | Registers draft, then `GenerateInvoicePdfAsync` → renders preview images |
| Preview an **existing** draft | `$inv.sprev(true)` (aka `$inv.sedit`) | `req/sedit` | case `sedit` | Same as `sprep` but requires `id`, updates existing draft | | Preview an **existing** draft | `$inv.sprev(true)` (aka `$inv.sedit`) | `req/sedit` | case `sedit` | Same as `sprep` but requires `id`, updates existing draft |
| Cancel out of preview | dialog `cancel` handler | `req/sdel` | case `sdel` | `fds__remInvoice` — deletes the (unfinalised) draft row | | Cancel out of preview | dialog `cancel` handler | `req/sdel` | case `sdel` | `fds__remInvoice` — deletes the (unfinalised) draft row |
@@ -134,7 +136,8 @@ JSON into SQL parameters and calls, in one batch:
- **Existing draft**: `fds__setInvoice` (same parameter set, updates in place) - **Existing draft**: `fds__setInvoice` (same parameter set, updates in place)
→ `fds__createInvoice_Details` again. → `fds__createInvoice_Details` again.
`GenerateInvoicePdf` (still in `InvoiceService`) builds `FuchsPdf.FdsTextBlocks` `GenerateInvoicePdfAsync` (still in `InvoiceService`) builds `FuchsPdf.FdsTextBlocks`,
resolves any whitelisted mfr-portal images from the line-item text (ADR 0018),
and calls `IPdfService.WriteLetterAsync` + `ApplyInvoice`, which internally and calls `IPdfService.WriteLetterAsync` + `ApplyInvoice`, which internally
uses `InvoiceSetPricing.Build(...)` to turn the posted item contract into uses `InvoiceSetPricing.Build(...)` to turn the posted item contract into
ordered, priced/unpriced lines. While the invoice is **not yet final** ordered, priced/unpriced lines. While the invoice is **not yet final**
@@ -160,7 +163,7 @@ sequenceDiagram
C->>S: RegisterInvoiceAsync(invoice, change, invId) C->>S: RegisterInvoiceAsync(invoice, change, invId)
S->>DB: fds__createInvoice / fds__setInvoice + fds__createInvoice_Details S->>DB: fds__createInvoice / fds__setInvoice + fds__createInvoice_Details
DB-->>S: registered invoice row (Id, ...) DB-->>S: registered invoice row (Id, ...)
S->>S: GenerateInvoicePdf (draft=true -> watermark, no GiroCode) S->>S: GenerateInvoicePdfAsync (draft=true -> watermark, no GiroCode)
S-->>C: MigraDoc Document S-->>C: MigraDoc Document
C-->>JS: {id, img[] (base64 pages), total} C-->>JS: {id, img[] (base64 pages), total}
JS->>U: show PDF preview modal (confirm / cancel) JS->>U: show PDF preview modal (confirm / cancel)
@@ -180,6 +183,36 @@ Confirming the preview modal posts to **`req/sconf`**
`[InvoiceId] = fds__newInvoiceId(YEAR())` → format `R<year>-<0000>` `[InvoiceId] = fds__newInvoiceId(YEAR())` → format `R<year>-<0000>`
(the draft only ever had the internal numeric `Id`). `Version` is bumped (the draft only ever had the internal numeric `Id`). `Version` is bumped
and any linked "replaces" bookkeeping (storno/credit chains) is updated. and any linked "replaces" bookkeeping (storno/credit chains) is updated.
- **Invoice date** = `DateFinalized` — printed on the PDF and used as the
e-invoice issue date (`InvoiceDates.IssueDate`, ADR 0032).
- **Backdating (ADR 0032):** users with `fds_inv > 2` see a checkbox in the
preview dialog (`inv/dpreview` returns `backdate`) to date the invoice to
the last working day (Mon–Fri) of the previous month, as long as no
invoice has been finalised after that day. `req/sconf` then passes
`@backdate = 1`; `fds__fn_invoiceBackdate` is re-checked and the proc
throws 60001 (nothing finalised) if it is no longer allowed. Number year and
`DueDate` follow the backdated day. The proc serializes all finalisations
(transaction + `sp_getapplock`) so the check and the numbering cannot
interleave with a concurrent finalisation. The controller maps 60001 to
the "create again without backdating" message; a 0-row result (already
final/sent, e.g. double submit) is reported as "already finalised".
- **Same date everywhere (ADR 0035):** invoice lists, revenue reports,
the DATEV export (period + `Belegdatum`), request details and reminders
all use `[dbo].[fds__fn_invoiceDate]` — `DateFinalized` as German
calendar day, drafts fall back to `DateCreated` — so a backdated invoice
appears in the previous month everywhere, including DATEV. The reminder
PDF's own date follows the same rule (reminder `DateFinalized`).
- **Abschlagsrechnungen (ADR 0034):** `IntInvoiceAllocation` lines carry the
credited intermediate invoice in `reference_InvId` (seeded by
`fds__prepInvoice` and `fds__prepStorno_recreate`, round-tripped by the
editor as item `ref`); finalisation records it in
`fds__invoice_details.AllocatedTo_InvId` so the same Abschlagsrechnung is
not offered again. The claim only counts while the crediting invoice is
alive: once it is cancelled (storno, or replaced via storno + recreate)
`fds__fn_IntermediateIsAllocatedToOther` treats the Abschlagsrechnung as
free again and the next finalisation takes the allocation over. A
Stornorechnung (`'c'`) never claims an allocation, even though a storno
copy mirrors the credit lines.
2. On success (`IsFinal == true` returned), the controller: 2. On success (`IsFinal == true` returned), the controller:
- Reloads the invoice via `LoadInvoiceAsync`. - Reloads the invoice via `LoadInvoiceAsync`.
- Calls `StoreInvoiceDocumentFileAsync` → renders the **final** PDF - Calls `StoreInvoiceDocumentFileAsync` → renders the **final** PDF
@@ -215,7 +248,9 @@ flowchart TD
## 6. Sending the invoice by email ## 6. Sending the invoice by email
Email delivery is handled by `IComService` / `ProcessWebComService`, which Email delivery is handled by `IComService` / `ProcessWebComService`, which
talks to the **ProcessWeb Mailer API** (`push_com`, `comType=email`). talks to the **ProcessWeb Mailer API** (`POST /api/mailer/push_com`,
`comType="e"`; contract: https://processweb.de/api/mailer/fc, client side
described in `Docs/Concepts/processweb-mailer-interface.md`).
- `HandleRequestSconf` builds the HTML body via `BuildInvoiceBody(balance, - `HandleRequestSconf` builds the HTML body via `BuildInvoiceBody(balance,
paymentTerms)` — a fixed German thank-you text, the amount (if non-zero), paymentTerms)` — a fixed German thank-you text, the amount (if non-zero),
@@ -233,8 +268,12 @@ talks to the **ProcessWeb Mailer API** (`push_com`, `comType=email`).
- A signature is appended to the HTML body. - A signature is appended to the HTML body.
- If the mailer is disabled (`_settings.Enabled == false`), sending is a - If the mailer is disabled (`_settings.Enabled == false`), sending is a
documented no-op. documented no-op.
- Attachments are base64-encoded inline (`{ filename, mimeType, - Attachments are pre-validated against the Mailer's limits (size, count,
contentBase64 }`) and POSTed to the Mailer API. no empty file, extension rules) and POSTed as `multipart/form-data` —
envelope JSON in an unnamed field plus one file part per attachment, with
`attachmentCount` in the envelope.
- The Mailer's 200 response must confirm `attachmentsProcessed` equal to
the number sent; otherwise the send is reported as failed (ADR 0028).
- Only on a **successful** send does the controller call - Only on a **successful** send does the controller call
`fds__setInvoiceSent @auto=true`, which sets `IsSent = 1` — so a failed `fds__setInvoiceSent @auto=true`, which sets `IsSent = 1` — so a failed
mailer call leaves the invoice finalised but *not marked sent* (visible via mailer call leaves the invoice finalised but *not marked sent* (visible via
@@ -251,8 +290,10 @@ sequenceDiagram
C->>Com: SendEmailAsync("inv_<Id>", subject, body, email, "", {pdf}) C->>Com: SendEmailAsync("inv_<Id>", subject, body, email, "", {pdf})
Com->>Com: validate email, apply OverrideRecipient (dev), append signature Com->>Com: validate email, apply OverrideRecipient (dev), append signature
alt mailer enabled alt mailer enabled
Com->>API: POST push_com {comType:"email", attachments:[base64 pdf]} Com->>Com: MailerAttachmentPolicy.Validate(pdf)
API-->>Com: success/failure Com->>API: POST /api/mailer/push_com (multipart: envelope{comType:"e", attachmentCount:1} + pdf part)
API-->>Com: 200 {comStackID, attachmentsProcessed} / 4xx/5xx
Com->>Com: attachmentsProcessed == 1 ? success : failure
else mailer disabled else mailer disabled
Com-->>Com: no-op (logged) Com-->>Com: no-op (logged)
end end
@@ -271,10 +312,10 @@ Once an invoice is `IsFinal`, the request/invoice list menus
|---|---|---|---| |---|---|---|---|
| **Resend email** | `$inv.ccInv`/context menu → *(re-)send* → `HandleRequestResend` | `req/resend` | Re-renders the stored/registered PDF bytes (`RenderInvoicePdfBytesAsync`, no re-registration) and re-sends via `IComService`, **without** re-touching `IsSent` | | **Resend email** | `$inv.ccInv`/context menu → *(re-)send* → `HandleRequestResend` | `req/resend` | Re-renders the stored/registered PDF bytes (`RenderInvoicePdfBytesAsync`, no re-registration) and re-sends via `IComService`, **without** re-touching `IsSent` |
| **Mark as sent manually** | `$inv.sis(id)` | `inv/sis` | `fds__setInvoiceSent @auto=false` — for invoices sent outside the system (e.g. printed/posted) | | **Mark as sent manually** | `$inv.sis(id)` | `inv/sis` | `fds__setInvoiceSent @auto=false` — for invoices sent outside the system (e.g. printed/posted) |
| **View / display PDF** | `$inv.disp(id, 'inv')` | `inv/rdoc` | Renders page images for on-screen display only | | **View / display PDF** | `$inv.disp(id, 'inv')` | `inv/rdoc` | Renders page images for on-screen display only. If a finalised, non-external invoice has no stored PDF, it is regenerated and persisted first (ADR 0025) |
| **Download / open PDF** | `req/idoc` or `inv/pget`-derived | `req/idoc` | Serves the stored (or freshly rendered) PDF inline | | **Download / open PDF** | `req/idoc` or `inv/pget`-derived | `req/idoc` | Serves the stored (or freshly rendered) PDF inline |
| **Mark paid / unpaid** | `$inv.setPyd`/`setUpd` | `inv/setpyd` / `inv/setupd` | `fds__setInvoicePayed` / `fds__setInvoiceUNPayed` | | **Mark paid / unpaid** | `$inv.setPyd`/`setUpd` | `inv/setpyd` / `inv/setupd` | `fds__setInvoicePayed` / `fds__setInvoiceUNPayed` |
| **Storno (cancel)** | `$inv.storno` → `$inv.cSt` | `inv/storno` (mode `simple`/`copy`) | `fds__createStorno_simple` / `fds__createStorno_copy`; the replaced invoice is cross-linked (`Replaces_InvId`) and cancelled once the storno itself is finalised | | **Storno (cancel)** | `$inv.storno` → `$inv.cSt` (first `inv/pget`: refreshes mfr entities only for numeric mfr ids, no-op for Intranet ids) | `inv/storno` (mode `simple`/`copy`) | `fds__createStorno_simple` / `fds__createStorno_copy`; the replaced invoice is cross-linked (`Replaces_InvId`) and cancelled once the storno itself is finalised |
| **Credit note** | `$inv.credit` → `$inv.cSt` | `inv/credit` (mode `credit`) | `fds__createCredit_simple` — same preview/finalise/email loop as a normal invoice | | **Credit note** | `$inv.credit` → `$inv.cSt` | `inv/credit` (mode `credit`) | `fds__createCredit_simple` — same preview/finalise/email loop as a normal invoice |
| **Continue editing a draft** | `$inv.cntInv` / `clCntInv` | `inv/get` | Only available while `isFinal == false`; re-opens the same stateless editor | | **Continue editing a draft** | `$inv.cntInv` / `clCntInv` | `inv/get` | Only available while `isFinal == false`; re-opens the same stateless editor |
+121 -152
View File
@@ -1,46 +1,49 @@
# Invoice "Set" Pricing — Design & Front-/Back-end Contract # Invoice "Set" Pricing — Design & Front-/Back-end Contract
> Governed by ADR [0009](Decisions/0009-block-setprice-modes-are-irreversible-mutations.md) > Governed by ADR [0019](Decisions/0019-set-price-modes-are-keyed-on-the-set.md)
> (the two menu modes), ADR [0008](Decisions/0008-invoices-and-reminders-fully-backend-authoritative.md) > (the two menu modes — **supersedes** ADR
> (everything server-side, PDF == editor) and ADR > [0009](Decisions/0009-block-setprice-modes-are-irreversible-mutations.md), which had keyed them on
> [0016](Decisions/0016-user-defined-set-structure.md) (set membership is an explicit, > the service-request block), ADR
> user-editable parent link; sets may nest — see "Set structure" below). ADR 0009 **redefined** the two menu modes from the > [0008](Decisions/0008-invoices-and-reminders-fully-backend-authoritative.md) (everything
> reversible, non-mutating display toggles this document previously described into irreversible, > server-side, PDF == editor) and ADR [0016](Decisions/0016-user-defined-set-structure.md) (set
> per-service-request-block mutations — the text below reflects the redefinition. > membership is an explicit, user-editable parent link; sets may nest — see "Set structure" below).
There are **three** separate set-price operations. They fall into two families that must not be There are **three** set-price operations. All three are **irreversible mutations** of the
confused, because they group items by different keys and differ in whether they mutate the data: authoritative cached draft session (`InvoiceDraftSession.Req`) — none is a display toggle — and all
three group by the **same key: the set** (ADR 0019).
| # | Operation | Trigger | Grouped by | Effect | | # | Operation | Trigger | Scope | Effect |
|---|---|---|---|---| |---|---|---|---|---|
| **1** | **Set-item switch** | row context button on a single `type == "set"` item (`$inv.toSetPrice`), shown only when it has a `SetItmId` | `SetItmId` (the mfr set-item and its members) | sums members onto the set header, sets the members' prices to `null` — **one-way mutation** | | **1** | **Set-item switch** | row context button on a single `typ == "set"` row (`$inv.toSetPrice`) | that one set | sums the set's transitive descendants onto the set line and sets their prices to `null` |
| **2** | **"Set mit Preis"** | editor menu ("Set-Preisanzeige") | **`ServiceRequestId`** (the whole block) | inserts a dedicated **set row** per block (block sum as its price) and sets every item's price to **`null`** (items shown **without price**) — **irreversible mutation** | | **2** | **"Set mit Preis – Positionen ohne Preis"** | editor menu ("Set-Preisanzeige") | **every still-unconverted top-level set** of every block | same conversion, applied to all of them at once; the members' rows are **kept**, showing an empty price cell |
| **3** | **"Nur Set mit Preis"** | editor menu ("Set-Preisanzeige") | **`ServiceRequestId`** (the whole block) | inserts the dedicated **set row** per block and **removes** every item line from the block — **irreversible mutation** | | **3** | **"Nur Set mit Preis – Positionen ausgeblendet"** | editor menu ("Set-Preisanzeige") | **every top-level set** of every block | same conversion, and the set's whole subtree is **removed** from the dataset |
Only operation **1** reads `SetItmId`. Operations **2** and **3** ignore it entirely; their only What the two menu modes explicitly do **not** do (they did, under the superseded ADR 0009): they do
grouping key is the service request (the editor tbody block). See "All set-price functions" below not treat a service request as an implicit set, do not insert a "Gesamtumfang pauschal" row, and do
for the full breakdown. not clear anyone's set membership.
> **The two menu modes are irreversible data changes, not display toggles.** Choosing "Set mit - **Lines outside every set stay untouched** — individual price, listed as ordinary positions.
> Preis" or "Nur Set mit Preis" rewrites the block's items in the authoritative, server-cached Sets and standalone lines together make up the invoice total.
> draft session (`InvoiceDraftSession.Req` — the "cache dataset"): a dedicated set row is inserted - **Several sets stay several separate lines**, in the same block or across blocks.
> and members have their price set to `null` (mode 2) or are deleted (mode 3). There is **no** - **Only top-level sets are priced.** A nested set belongs to its parent's subtree, so it is blanked
> reversible toggle and **no** persisted `admin.setmode` render flag driving grouping. The only (mode 2) or removed (mode 3) with it and never priced twice.
> ways back are discarding the draft (reloads the DB state) or hand-editing the resulting rows — - **No sets, or a set without members → no-op** (no version bump, no history). The editor checks the
> the set row is a real, editable line item precisely so the user can adjust the set value authoritative state first and, when the invoice has no set at all, points the user at
> afterwards. "Set-Struktur bearbeiten" instead of opening the mode dialog.
- **Repeatable, still one-way.** Running a mode again only converts sets that are still unconverted;
> **`ItemPrices` / `admin.setmode` display-flag model was removed.** The earlier design persisted nothing converts back. The only ways back are discarding the draft (reloads the DB state) or
> a `setmode:<mode>` token in `InvoiceOptions` and re-rendered set-item groups per that flag at hand-editing the resulting rows — the set line is a real, editable item precisely for that.
> render time (`InvoiceSetPricing.Build`). Under ADR 0009 the two menu modes are one-shot
> mutations, so there is no display state to persist or toggle. A stale `setmode:`/`itemprices`
> token degrades safely (ignored) and is never (re-)persisted.
> **Totals are unaffected.** The invoice total is taken from the registration balance > **Totals are unaffected.** The invoice total is taken from the registration balance
> (`InvoiceBalance` / `InvoiceBalance_net`), not by summing the rendered lines. Each operation > (`InvoiceBalance` / `InvoiceBalance_net`), not by summing the rendered lines, and every operation
> conserves the total: the inserted set row's value equals the sum of the items it blanks (mode 2) > conserves it anyway: the set line receives exactly the sum of the lines it blanks or removes.
> or removes (mode 3), and the set-item switch (mode 1) writes exactly the members' sum onto the > `null` (not `0`) marks "no price" — an empty cell excluded from the sum, distinct from a genuine
> header. > `0,00 €`.
> **The `ItemPrices` / `admin.setmode` display-flag model is gone.** The pre-0009 design persisted a
> `setmode:<mode>` token in `InvoiceOptions` and re-grouped at render time. The modes are one-shot
> mutations, so there is no display state to persist or toggle; a stale `setmode:`/`itemprices`
> token degrades safely (ignored) and is never re-persisted.
## Set structure (ADR 0016): which line belongs to which set ## Set structure (ADR 0016): which line belongs to which set
@@ -59,11 +62,11 @@ for the full breakdown.
siblings). The ◀ / ▶ buttons post `set.level` −1 / +1 (outdent to the siblings). The ◀ / ▶ buttons post `set.level` −1 / +1 (outdent to the
grandparent / indent under the nearest preceding sibling set). Sets can be folded via the grandparent / indent under the nearest preceding sibling set). Sets can be folded via the
triangle (view state only). The per-block bar creates a triangle (view state only). The per-block bar creates a
new **Set** (zero-priced header, convertible via function 1) or **Text** line inside any set new **Set** (zero-priced header, convertible via the operations above) or **Text** line inside any
or at the top level → `set.create { typ, title, parent }`. set or at the top level → `set.create { typ, title, parent }`.
- **Sets may contain sets.** A set dropped onto another set keeps its own members (one level - **Sets may contain sets.** A set dropped onto another set keeps its own members (one level
deeper). Function 1 (`item.setprice`) sums the header's **transitive** descendants and nulls deeper). Every conversion sums the header's **transitive** descendants and nulls them all;
them all; converting inner-then-outer or outer directly conserves the total either way. converting inner-then-outer or outer directly conserves the total either way.
- **Only sets are drop targets; a line never leaves its block.** Cycles (a set onto its own - **Only sets are drop targets; a line never leaves its block.** Cycles (a set onto its own
descendant) and no-op moves are rejected server-side (`InvoiceSetStructure`), the browser descendant) and no-op moves are rejected server-side (`InvoiceSetStructure`), the browser
merely greys them out. merely greys them out.
@@ -75,65 +78,72 @@ for the full breakdown.
## Back-end ## Back-end
> **Migration status (ADR 0009).** The code below still reflects the previous - `Fuchs/Services/InvoiceDraftEditService.cs` — the three triggers:
> `admin.setmode` + `InvoiceSetPricing.Build` **display-mode** implementation for - `ConvertSetHeader(block, lines, header)` is the **single conversion primitive**: sums the
> functions 2 & 3. Under ADR 0009 those two functions become per-block mutations in header's transitive descendants (`InvoiceSetStructure.Descendants`) onto the header
> `InvoiceDraftEditService` (insert set row + blank/remove members); the render-time (`v`/`vt`/`vv`/`vs`/`vsv`, adopting the members' VAT rate when the header has none), nulls every
> `Build`/`ModeFromInvoiceOptions`/`setmode` display path for them is being retired. descendant's price fields, and mirrors the result onto the `items` contract shape
> Function 1 (`ApplyItemSetPrice`) is unaffected. Update this section as the (`total_net` on the header, `null` on the members) so the editor and the PDF see the conversion
> migration lands so it stays a faithful description of the code. without a reload.
- `ApplyItemSetPrice` (`item.setprice`) resolves one header by `Ref` and delegates. It is a no-op
- `Fuchs/code/InvoiceSetPricing.cs` — the authoritative transformation: when the `Ref` is unknown, is not a set line, or the set is **already converted** (own price
`SetDisplayMode` + `Build(items, mode)` → `InvoiceSetLine`s **in input order**, each with non-zero) — that guard keeps a repeated delta from re-summing already-nulled members to 0.
`ShowPrice`, `IsSetHeader` and `Level` (nesting depth). A line is governed by its nearest - `ApplySetPricing(removeMembers)` (`block.setprice` / `block.setonly`) walks every block, takes
*converted* ancestor set (blank in SetPrice, dropped in SetOnly); an unconverted header is the **top-level** set lines (`IsSet` && `ParentOf == null`), converts the unconverted ones via
blank with individually priced members. `ModeFromInvoiceOptions(...)` reads the mode the primitive, and — for `setonly` — removes each such set's whole subtree from `itm` and
from the invoice options. Fully covered by `Fuchs.Tests/InvoiceSetPricingTests.cs`. `items` (`RemoveById`). Sets with no members, and blocks/invoices without sets, are skipped.
- `Fuchs/code/InvoiceSetStructure.cs` — the pure set forest (parent links, levels, depth-first - `Fuchs/code/InvoiceSetStructure.cs` — the pure set forest (parent links, levels, depth-first
linearisation, assign / change level / create); `Fuchs/Services/InvoiceDraftSetStructureDeltas.cs` linearisation, assign / change level / create); `Fuchs/Services/InvoiceDraftSetStructureDeltas.cs`
maps the `set.*` patch targets onto it. Covered by `InvoiceSetStructureTests` + `InvoiceDraftServiceTests`. maps the `set.*` patch targets onto it. Covered by `InvoiceSetStructureTests` + `InvoiceDraftServiceTests`.
- `FuchsPdf.ApplyInvoice` renders through `InvoiceSetPricing.Build`: lines with - `Fuchs/code/InvoiceSetPricing.cs` — the render transformation:
`ShowPrice == false` render **blank** price/total cells (not `0,00 €`), and the `SetDisplayMode` + `Build(items, mode)` → `InvoiceSetLine`s **in input order**, each with
set header line is rendered emphasised. Invoices without sets pass through `ShowPrice`, `IsSetHeader` and `Level` (nesting depth). A line is governed by its nearest
unchanged. *converted* ancestor set (blank in SetPrice, dropped in SetOnly); an unconverted header is
blank with individually priced members. Because the menu modes preserve set membership, this is
what renders their result — no special case is needed for them. Fully covered by
`Fuchs.Tests/InvoiceSetPricingTests.cs`.
- `InvoiceDraftEditService.BuildSetDisplay` runs the same `Build` for the editor and publishes
`setDisplay[id] = { p: showPrice, h: isSetHeader }`, so the rows always match the PDF without any
client-side pricing rule.
- `FuchsPdf.ApplyInvoice` renders blocks that carry a set through `InvoiceSetPricing.Build` and
set-free blocks flat; lines with `ShowPrice == false` (or a `null` `total_net`) render **blank**
price/total cells (not `0,00 €`), and set header lines are emphasised.
## Front-end contract (implemented in the invoice editor) ## Front-end contract (implemented in the invoice editor)
Wired in `Fuchs/js/intranet/modules/fis.inv_shared.js` (bundled to Wired in `Fuchs/js/intranet/modules/fis.inv_shared.js` (bundled to
`wwwroot/web/fis.inv.de.js` via gulp `min:js`): `wwwroot/web/fis.inv.de.js` via gulp `min:js`):
1. **Menu modes** — the "Set-Preisanzeige" menu (`$inv.ssetmode` → `$inv.setSetmode`, 1. **Menu modes** — the "Set-Preisanzeige" menu (`$inv.ssetmode` → `$inv.setSetmode`, menu entry
menu entry `setm`, label `$ict.setm`) offers "Set mit Preis" and "Nur Set mit `setm`, label `$ict.setm`) offers "Set mit Preis" and "Nur Set mit Preis". `$inv.hasSets(state)`
Preis". Selecting one posts a **mutating delta** (grouped by service-request first checks the authoritative `dstate` for a line that points at a set line; if there is none,
block) to `inv/dpatch`; the server rewrites the block's items in the cached the user is told to define a set first ($rct.toSetMn) and nothing is posted. Otherwise selecting a
session (inserts the set row, blanks or removes members) and pushes the new mode posts a **mutating delta** (`block.setprice` / `block.setonly`) to `inv/dpatch`; the server
state back via `draftReady`/`dstate`. The browser performs **no** grouping or rewrites the sets in the cached session and pushes the new state back via `draftReady`/`dstate`.
pricing math (ADR 0008/0009) — it only posts the chosen mode and re-renders the The browser performs **no** grouping or pricing math (ADR 0008/0019) — it only posts the chosen
server's `req`/`sums`. There is no persisted `admin.setmode` display flag for mode and re-renders the server's `req`/`sums`. Pending local edits are flushed first
these two modes. (`$inv.t_fds_inv()`) so a delayed `block.replace` cannot land after the conversion. The menu
entry is always offered (`$inv.emOpt`), because a mode can be run again for a set added later.
2. **Item shape** — `$inv.invSumUpdate` now posts each request block's 2. **Item shape** — `$inv.invSumUpdate` posts each request block's `items[]` in the back-end
`items[]` in the back-end contract shape via `$inv.itemToContract`: contract shape via `$inv.itemToContract`:
`{ id, type, title, desc, qty, price_net, total_net, vat }`. (Previously the `{ id, type, title, desc, qty, price_net, total_net, vat }`. (Previously the
editor only posted the legacy on-screen `itm`/`co` objects, which editor only posted the legacy on-screen `itm`/`co` objects, which
`FdsInvoiceData.InvoiceItems` does not read — so line items never reached the `FdsInvoiceData.InvoiceItems` does not read — so line items never reached the
C# PDF. This change closes that gap for **all** invoices, not just sets.) C# PDF. This change closes that gap for **all** invoices, not just sets.)
3. **Set flags (function 1 only)** — `invSumUpdate` tags items as it builds `items[]`: 3. **Set flags** — `invSumUpdate` tags items as it builds `items[]`: an item with `type === 'set'`
an item with `type === 'set'` is a header (`id` = its set id); a member's is a header (`id` = its set id); a member's `setId` is taken directly from the `SetItmId` field on
`setId` is taken directly from the `SetItmId` field on the row the row (`rrx.SetItmId` — initially populated by `fds__prepInvoice`'s `[SetItmID]` window
(`rrx.SetItmId` — initially populated by `fds__prepInvoice`'s `[SetItmID]` window function, function, anchored on the still-unconverted, zero-priced `'set'` header that owns it, and
anchored on the still-unconverted, zero-priced `'set'` header that owns it, and thereafter thereafter maintained by the server per ADR 0016, including for a **nested set**, which is a
maintained by the server per ADR 0016, including for a **nested set**, which is a member of member of its parent set) — **not** re-derived from row order in the browser. `mfr__items` itself
its parent set) — **not** re-derived from row order in the browser. `mfr__items` itself still has no still has no explicit member link; `fds__prepInvoice` computes `SetItmId` per request from the
explicit member link; `fds__prepInvoice` computes `SetItmId` per request from the item list, so only items the server actually attributes to a set are tagged, and unrelated items
item list, so only items the server actually attributes to a set are tagged, and following a set in the list are never swept in. The header row's own `SetItmId` self-references
unrelated items following a set in the list are never swept in. The header row's its own id (rather than being `null`); it is explicitly excluded from being its own member both
own `SetItmId` self-references its own id (rather than being `null`); it is here (`sid !== citem.id`) and in `InvoiceSetStructure.ParentOf`. These flags feed **all three**
explicitly excluded from being its own member both here (`sid !== citem.id`) and set-price operations (ADR 0019).
in `InvoiceDraftEditService.ApplyItemSetPrice`. These flags feed **only** the
set-item switch (function 1); the two menu modes (functions 2 & 3) ignore
`SetItmId` and group by service-request block.
### Editor → backend field normalization (`$inv.invcPayload`) ### Editor → backend field normalization (`$inv.invcPayload`)
The editor's internal model keeps the long-standing key names, but the migrated C# The editor's internal model keeps the long-standing key names, but the migrated C#
@@ -158,9 +168,7 @@ no per-rate `vat_*` keys.
the highest rate. the highest rate.
The editor's running **total is unaffected by any set-price operation**, matching The editor's running **total is unaffected by any set-price operation**, matching
the registration balance — each operation conserves the total (the set row's value the registration balance — each operation conserves the total.
equals the members it blanks/removes; the set-item switch writes exactly the
members' sum onto the header).
### Why the trigger lives in the editor ### Why the trigger lives in the editor
The choice of *when* to apply a set-price operation is only known where the invoice The choice of *when* to apply a set-price operation is only known where the invoice
@@ -169,83 +177,44 @@ against the cached draft session — the editor merely names the target (a set-i
`Ref` for function 1, or the chosen menu mode for functions 2 & 3) and re-renders `Ref` for function 1, or the chosen menu mode for functions 2 & 3) and re-renders
the server's result. The back-end stays the single, tested authority for how each the server's result. The back-end stays the single, tested authority for how each
operation rewrites the lines; the editor never re-implements the grouping, the operation rewrites the lines; the editor never re-implements the grouping, the
per-block aggregation, or the pricing rules (ADR 0008/0009). aggregation, or the pricing rules (ADR 0008/0019).
## All set-price functions: before/after comparison ## Before/after per operation
There are **three** distinct functions, and all three are **mutations** of the ### 1. The set-item switch (`item.setprice`, one set)
authoritative cached draft session (`InvoiceDraftSession.Req`) — none is a
transient, freely-reversible view flag. They differ in what they group by and
what they touch:
- **Function 1 — the set-item switch** (`item.setprice`) groups by `SetItmId` Triggered from the invoice editor's row context menu (`$inv.toSetPrice`). The context button is
(one mfr set-item and its members) and is triggered per set row. shown — and the operation available — **only** on a row that is `typ == "set"` and is still
- **Functions 2 & 3 — the two menu modes** ("Set mit Preis" / "Nur Set mit unconverted (own price `0`).
Preis") group by `ServiceRequestId` (the whole block), ignore `SetItmId`
entirely, and are triggered once from the "Set-Preisanzeige" menu.
Only function 1 reads `SetItmId`. All three are one-way; the only escape hatch | Aspect | Before | After |
is discarding the draft or hand-editing the resulting rows.
### 1. The set-item switch (`item.setprice` patch, single set, mutating)
Triggered from the invoice editor's row context menu (`$inv.toSetPrice`), applied
server-side by `InvoiceDraftEditService.ApplyItemSetPrice`. The context button is
shown — and the operation available — **only** on a row that is `type == "set"`
**and** carries a `SetItmId` (and is still unconverted, own price `0`); a row
missing either condition never offers it. It gives a set-item its "own price": the
members' values are summed onto the header and the members' prices are set to
`null` (empty cell, excluded from the sum — not `0`). This
conversion is **one-way** — there is no patch to move a converted set back to
separately-priced members; the user would re-edit the individual line prices by
hand. It is the **only** function keyed on `SetItmId`.
| Aspect | Before the switch | After the switch |
|---|---|---| |---|---|---|
| Set header item (`type == "set"`, `id == Ref`) price | `0` (zero-priced, as delivered by `fds__prepInvoice`) | Price fields (`total_net`/`v`/`vt` + VAT amounts `vv`/`vs`/`vsv`) replaced by the sum of all its members' corresponding values | | Set line (`id == Ref`) price | `0` (as delivered by `fds__prepInvoice`, or as created in the structure dialog) | `v`/`vt`/`vv`/`vs`/`vsv` (and `items[].total_net`) = the sum of its transitive descendants |
| Member items (`SetItmId == Ref`, excluding the header itself) | Each shows its own individual `total_net` / VAT amounts | Each price field is set to **`null`** (`v`/`vt`/`vv`/`vs`/`vsv` all `null`, not `0`) → renders an **empty** price/total cell and is excluded from the sum; the row itself stays in the list | | Descendant lines | each shows its own price | every price field set to **`null`** (not `0`) → empty cell, excluded from the sum; the rows stay |
| Membership determination | N/A — membership already fixed by the server (`fds__prepInvoice`'s `[SetItmID]` window function) | **Unchanged** — the switch only sums/nulls the items the server already tagged; it never re-derives or reassigns `SetItmId` | | Membership | fixed by the server (`fds__prepInvoice` + the user's edits, ADR 0016) | **unchanged** — never re-derived or reassigned |
| Items **not** tagged with this header's `SetItmId` (e.g. unrelated items following the set in the same block) | Untouched | **Still untouched** — never swept in, regardless of row order/position | | Lines outside the set | untouched | **still untouched**, regardless of row order |
| Draft version / history | — | Version bumped by one; an `item.setprice` history entry recorded with old/new header value | | Draft version / history | — | version +1, `item.setprice` entry with old/new header value |
| Invoice total (`Sums.TotalNet`/`TotalGross`) | Sum of all individual item prices (header 0 + each member's own price) | **Unchanged** — same total, because the header received exactly the sum of its members | | Invoice total | — | **unchanged** |
| Idempotency / no-ops | `Ref` unknown, or `Ref` does not point at a `type == 'set'` header → **no-op**: no version bump, no history entry | Same guard still applies after conversion — re-issuing the patch against a non-header `Ref` remains a no-op | | No-ops | unknown `Ref`, `Ref` is not a set line, **or the set is already converted** → no version bump, no history | |
### 2 & 3. The two menu modes (per service-request block, mutating) ### 2 & 3. The two menu modes (every set, one action)
Triggered once from the editor's "Set-Preisanzeige" menu (`$inv.ssetmode` →
`$inv.setSetmode`) and applied server-side per **service-request block**
(`ServiceRequestId`), independent of any `type == "set"` item or `SetItmId`.
Both are **irreversible** and rewrite the block's items in the cached session.
For each block, a dedicated, emphasised **set row** is inserted (see "The
dedicated set row" below) carrying the block's aggregated value as its price;
then, depending on the mode, the block's original items are either blanked or
removed:
| Aspect | **"Set mit Preis"** (mode 2) | **"Nur Set mit Preis"** (mode 3) | | Aspect | **"Set mit Preis"** (mode 2) | **"Nur Set mit Preis"** (mode 3) |
|---|---|---| |---|---|---|
| Grouping key | `ServiceRequestId` (block) | `ServiceRequestId` (block) | | Grouping key | the set (top-level sets of every block) | the set (top-level sets of every block) |
| Inserted set row | one per block, price = block's aggregated net (+ VAT/service splits) | one per block, same value | | Set line | receives the sum of its subtree | receives the sum of its subtree |
| Original item rows | **kept**, but each price field (`v`/`vt`/`vv`/`vs`/`vsv`) is set to **`null`** (not `0`) → renders an **empty** price/total cell and is excluded from the block sum | **removed** from the block entirely | | Its member rows | **kept**, every price field `null` → empty price/total cell | **removed** from `itm` and `items` entirely (nested set lines included) |
| `SetItmId` | ignored | ignored | | Already-converted set | left as it is | keeps its price, members still removed |
| Lines in no set | **untouched**, individually priced | **untouched**, individually priced |
| Several sets | each stays its own priced line | each stays its own priced line |
| Reversibility | irreversible (discard draft or hand-edit) | irreversible (discard draft or hand-edit) | | Reversibility | irreversible (discard draft or hand-edit) | irreversible (discard draft or hand-edit) |
| Invoice total | **unchanged** — the set row's value equals the sum of the block's members it blanks | **unchanged** — the set row's value equals the sum of the removed lines | | Invoice total | **unchanged** | **unchanged** |
| Empty block | no-op | no-op | | Invoice without sets / empty set | no-op (editor tells the user to define a set) | no-op (same) |
### The dedicated set row
Both menu modes insert a **real, editable line item** (its own id, rendered
emphasised in the editor and the PDF), not a reused block-heading row and not a
render-only overlay. Because it is a genuine row in the cached dataset, the user
can adjust the set value afterwards with an ordinary item edit — that hand-edit
is the intended and only "undo" for the conversion (ADR
[0009](Decisions/0009-block-setprice-modes-are-irreversible-mutations.md)).
## Persistence note ## Persistence note
Draft/preview PDFs render straight from the cached draft session, so the contract Draft/preview PDFs render straight from the cached draft session, so the contract
works end-to-end for previews and creation. The two menu modes bake their result works end-to-end for previews and creation. The menu modes bake their result directly into the
directly into the session's items (a set row plus blanked/removed members), so no session's items (set line priced, members blanked or removed), so no `setmode:` display token is
`setmode:` display token is needed or persisted; the finalised document is needed or persisted; the finalised document is rendered once and stored as a file. Persisting the
rendered once and stored as a file. Persisting the per-item `type`/`setId` flags per-item `type`/`setId` flags (an SSDT + `fds__createInvoice_Details` change) is only required if a
(an SSDT + `fds__createInvoice_Details` change) is only required if a finalised finalised invoice must be **re-generated** from stored items later — not done here.
invoice must be **re-generated** from stored items later — not done here.
+5
View File
@@ -49,6 +49,11 @@ Serviceaufträge kommen aus dem MFR-ERP-System und werden regelmäßig automatis
### Erstellen / Bearbeiten ### Erstellen / Bearbeiten
1. Aus einem Serviceauftrag eine Rechnung **vorbereiten** → Vorschau prüfen. 1. Aus einem Serviceauftrag eine Rechnung **vorbereiten** → Vorschau prüfen.
2. **Speichern** (Entwurf) oder weiter **bearbeiten**. Entwürfe tragen ein Wasserzeichen. 2. **Speichern** (Entwurf) oder weiter **bearbeiten**. Entwürfe tragen ein Wasserzeichen.
3. **Rechnung erstellen** in der Vorschau vergibt Rechnungsnummer und Rechnungsdatum (= Tag der Erstellung).
- **Rechnungsdatum Vormonat (nur Admins):** Solange im neuen Monat noch keine Rechnung erstellt wurde,
erscheint in der Vorschau das Häkchen „Rechnungsdatum auf den letzten Werktag des Vormonats setzen
(TT.MM.JJJJ)“. Gesetzt, erhält die Rechnung dieses Datum; Rechnungsnummer und Fälligkeit richten sich
danach. Werktage sind Mo–Fr (Feiertage werden nicht berücksichtigt).
### Sets (Positionen zusammenfassen) ### Sets (Positionen zusammenfassen)
- Ein **Set** aus mfr fasst die darauf folgenden Positionen zusammen; mit **„Auf Setpreis - Ein **Set** aus mfr fasst die darauf folgenden Positionen zusammen; mit **„Auf Setpreis
@@ -1,933 +0,0 @@
# Agent Instructions: Key Vault + DPAPI — Multi-App Architektur (15 Projekte, 1 Server)
## Ausgangslage
- 15 Anwendungen (Web Apps, Console Apps, Windows Services, Worker Services)
- Alle laufen auf demselben Windows-Server
- Ein gemeinsamer Azure Key Vault
- Secrets sind aktuell in `appsettings.json`, `web.config` oder Umgebungsvariablen
- Zwei bestehende Shared Projects: **OCORE** und **OCORE_web**
---
## Aufteilung zwischen OCORE und OCORE_web
```
OCORE OCORE_web
────────────────────────────── ──────────────────────────────
Alles was NICHT Web-spezifisch Nur was WebApplicationBuilder
ist. Funktioniert für: benötigt. Funktioniert für:
- Console Apps - ASP.NET Core Web Apps
- Windows Services - ASP.NET Core Web APIs
- Worker Services - Razor Pages / MVC
- WinForms / WPF
- Web Apps (via OCORE_web)
Enthält: Enthält:
AppSecretsOptions SecretManagementWebExtensions
DpapiSecretsCache (eine einzige Datei)
DpapiCacheConfigurationSource
KeyVaultSyncService
PrefixKeyVaultSecretManager
SecretManagementExtensions
(IHostBuilder)
```
> OCORE_web referenziert OCORE — d.h. Web Apps brauchen nur OCORE_web
> zu referenzieren, OCORE kommt transitiv mit.
---
## Architektur-Übersicht
```
┌─────────────────────────────────────────────────────────────────┐
│ Azure Key Vault │
│ │
│ crm--ConnectionStrings--Database → CRM Web App │
│ crm--ExternalApi--ApiKey → CRM Web App │
│ erp--ConnectionStrings--Database → ERP Web App │
│ erp--Smtp--Password → ERP Web App │
│ importer--ConnectionStrings--Db → Importer Console App │
│ scheduler--ConnectionStrings--Db → Scheduler Windows Svc │
│ ... │
└──────────────────────────┬──────────────────────────────────────┘
│ Managed Identity (Server-Level)
┌──────────────────────────▼──────────────────────────────────────┐
│ Windows Server │
│ │
│ C:\inetpub\crm\ secrets.cache (DPAPI, crm-Entropy) │
│ C:\inetpub\erp\ secrets.cache (DPAPI, erp-Entropy) │
│ C:\services\importer\ secrets.cache (DPAPI, importer-Entropy) │
│ C:\services\scheduler\secrets.cache (DPAPI, scheduler-Entropy)│
│ ... │
│ │
│ OCORE.dll ← alle 15 Apps referenzieren dies │
│ OCORE_web.dll ← nur Web Apps referenzieren dies zusätzlich │
└─────────────────────────────────────────────────────────────────┘
```
### Wichtige Designentscheidungen
| Thema | Entscheidung | Begründung |
|---|---|---|
| Key Vault | Ein gemeinsamer | Einfacher zu verwalten, RBAC auf Secret-Ebene möglich |
| Namespacing | `{appname}--{key}` Präfix | Verhindert Kollisionen zwischen Apps |
| DPAPI Scope | `LocalMachine` + App-spezifische Entropy | Isolation: App A kann Cache von App B nicht lesen |
| Basiscode | OCORE | Kein Web-Framework nötig, funktioniert überall |
| Web-Extension | OCORE_web | Nur eine Datei, dünne Schicht über OCORE |
| App-Identity | Eine Server-Managed-Identity | Alle Apps teilen dieselbe Identity, RBAC per Secret |
---
## Teil 1: OCORE — NuGet-Pakete hinzufügen
```bash
cd OCORE
dotnet add package Azure.Extensions.AspNetCore.Configuration.Secrets
dotnet add package Azure.Identity
dotnet add package Azure.Security.KeyVault.Secrets
dotnet add package Microsoft.Extensions.Hosting.Abstractions
dotnet add package Microsoft.Extensions.Configuration.Abstractions
dotnet add package Microsoft.Extensions.Logging.Abstractions
```
---
## Teil 2: OCORE — Dateien anlegen
Neue Ordnerstruktur **innerhalb von OCORE** (bestehende Struktur bleibt unverändert):
```
OCORE/
... (bestehende Dateien)
Secrets/
Models/
AppSecretsOptions.cs
Cache/
DpapiSecretsCache.cs
DpapiCacheConfigurationSource.cs
Sync/
KeyVaultSyncService.cs
Extensions/
SecretManagementExtensions.cs ← IHostBuilder Extension + gemeinsame Logik
PrefixKeyVaultSecretManager.cs
```
---
### 2.1 OCORE/Secrets/Models/AppSecretsOptions.cs
```csharp
namespace OCORE.Secrets.Models;
public class AppSecretsOptions
{
public const string SectionName = "SecretManagement";
/// <summary>
/// Vollständige URI des Azure Key Vault.
/// Beispiel: https://company-vault.vault.azure.net/
/// </summary>
public string VaultUri { get; set; } = string.Empty;
/// <summary>
/// App-Name als Präfix für alle Secrets in Key Vault.
/// Beispiel: "crm" → Secret "crm--ConnectionStrings--Database"
/// Nur Kleinbuchstaben und Bindestriche (Key Vault Konvention).
/// </summary>
public string AppName { get; set; } = string.Empty;
/// <summary>
/// Pfad zur DPAPI-Cache-Datei (relativ zum App-Verzeichnis).
/// </summary>
public string CacheFilePath { get; set; } = "secrets.cache";
/// <summary>
/// Sync-Intervall in Stunden.
/// </summary>
public int SyncIntervalHours { get; set; } = 6;
/// <summary>
/// Secret-Namen ohne App-Präfix.
/// Beispiel: ["ConnectionStrings--Database", "ExternalApi--ApiKey"]
/// </summary>
public List<string> ManagedSecretKeys { get; set; } = [];
}
```
---
### 2.2 OCORE/Secrets/Cache/DpapiSecretsCache.cs
```csharp
using System.Security.Cryptography;
using System.Text;
using System.Text.Json;
using Microsoft.Extensions.Logging;
namespace OCORE.Secrets.Cache;
public interface IDpapiSecretsCache
{
Task WriteAsync(Dictionary<string, string> secrets);
Task<Dictionary<string, string>> ReadAsync();
bool Exists();
}
public class DpapiSecretsCache : IDpapiSecretsCache
{
private readonly string _filePath;
private readonly byte[] _entropy;
private readonly ILogger<DpapiSecretsCache> _logger;
/// <summary>
/// Entropy wird aus AppName abgeleitet — verhindert dass eine andere App
/// auf demselben Server den Cache dieser App lesen kann.
/// </summary>
public DpapiSecretsCache(string filePath, string appName, ILogger<DpapiSecretsCache> logger)
{
_filePath = filePath;
_entropy = Encoding.UTF8.GetBytes($"dpapi-entropy-{appName}");
_logger = logger;
}
public async Task WriteAsync(Dictionary<string, string> secrets)
{
try
{
var json = JsonSerializer.SerializeToUtf8Bytes(secrets);
var encrypted = ProtectedData.Protect(json, _entropy, DataProtectionScope.LocalMachine);
await File.WriteAllBytesAsync(_filePath, encrypted);
_logger.LogInformation("[OCORE.Secrets] DPAPI Cache geschrieben: {Count} Secrets → {Path}",
secrets.Count, _filePath);
}
catch (Exception ex)
{
_logger.LogError(ex, "[OCORE.Secrets] DPAPI Cache konnte nicht geschrieben werden: {Path}", _filePath);
}
}
public async Task<Dictionary<string, string>> ReadAsync()
{
if (!Exists()) return [];
try
{
var encrypted = await File.ReadAllBytesAsync(_filePath);
var decrypted = ProtectedData.Unprotect(encrypted, _entropy, DataProtectionScope.LocalMachine);
return JsonSerializer.Deserialize<Dictionary<string, string>>(decrypted) ?? [];
}
catch (CryptographicException ex)
{
_logger.LogError(ex,
"[OCORE.Secrets] DPAPI Entschlüsselung fehlgeschlagen — AppName korrekt? {Path}", _filePath);
return [];
}
catch (Exception ex)
{
_logger.LogError(ex, "[OCORE.Secrets] DPAPI Cache Lesefehler: {Path}", _filePath);
return [];
}
}
public bool Exists() => File.Exists(_filePath);
}
```
---
### 2.3 OCORE/Secrets/Cache/DpapiCacheConfigurationSource.cs
```csharp
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.Logging;
namespace OCORE.Secrets.Cache;
public class DpapiCacheConfigurationSource : IConfigurationSource
{
private readonly string _filePath;
private readonly string _appName;
public DpapiCacheConfigurationSource(string filePath, string appName)
{
_filePath = filePath;
_appName = appName;
}
public IConfigurationProvider Build(IConfigurationBuilder builder)
=> new DpapiCacheConfigurationProvider(_filePath, _appName);
}
public class DpapiCacheConfigurationProvider : ConfigurationProvider
{
private readonly string _filePath;
private readonly string _appName;
public DpapiCacheConfigurationProvider(string filePath, string appName)
{
_filePath = filePath;
_appName = appName;
}
public override void Load()
{
if (!File.Exists(_filePath)) return;
try
{
using var loggerFactory = LoggerFactory.Create(b => b.AddConsole());
var logger = loggerFactory.CreateLogger<DpapiSecretsCache>();
var cache = new DpapiSecretsCache(_filePath, _appName, logger);
var secrets = cache.ReadAsync().GetAwaiter().GetResult();
// "crm--ConnectionStrings--Database" → "ConnectionStrings:Database"
var prefix = $"{_appName}--";
Data = secrets
.Where(kvp => kvp.Key.StartsWith(prefix, StringComparison.OrdinalIgnoreCase))
.ToDictionary(
kvp => kvp.Key[prefix.Length..].Replace("--", ":"),
kvp => (string?)kvp.Value);
}
catch (Exception ex)
{
Console.WriteLine($"[OCORE.Secrets] DPAPI Cache Ladefehler: {ex.Message}");
Data = [];
}
}
}
```
---
### 2.4 OCORE/Secrets/Sync/KeyVaultSyncService.cs
```csharp
using Azure.Security.KeyVault.Secrets;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using OCORE.Secrets.Cache;
using OCORE.Secrets.Models;
namespace OCORE.Secrets.Sync;
/// <summary>
/// Läuft als BackgroundService in jedem Host-Typ:
/// Web Apps, Console Apps, Windows Services, Worker Services.
/// Synct Secrets aus Key Vault → DPAPI-Cache, periodisch.
/// </summary>
public class KeyVaultSyncService : BackgroundService
{
private readonly SecretClient _secretClient;
private readonly IDpapiSecretsCache _cache;
private readonly IConfigurationRoot _configRoot;
private readonly AppSecretsOptions _options;
private readonly ILogger<KeyVaultSyncService> _logger;
public KeyVaultSyncService(
SecretClient secretClient,
IDpapiSecretsCache cache,
IConfiguration configuration,
IOptions<AppSecretsOptions> options,
ILogger<KeyVaultSyncService> logger)
{
_secretClient = secretClient;
_cache = cache;
_configRoot = (IConfigurationRoot)configuration;
_options = options.Value;
_logger = logger;
}
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
await SyncAsync(stoppingToken);
using var timer = new PeriodicTimer(TimeSpan.FromHours(_options.SyncIntervalHours));
while (await timer.WaitForNextTickAsync(stoppingToken))
{
await SyncAsync(stoppingToken);
}
}
private async Task SyncAsync(CancellationToken cancellationToken)
{
_logger.LogInformation("[OCORE.Secrets:{App}] Key Vault Sync gestartet", _options.AppName);
var synced = new Dictionary<string, string>();
foreach (var key in _options.ManagedSecretKeys)
{
var fullName = $"{_options.AppName}--{key}";
try
{
var secret = await _secretClient.GetSecretAsync(fullName, version: null, cancellationToken);
synced[fullName] = secret.Value.Value;
}
catch (Exception ex)
{
_logger.LogWarning("[OCORE.Secrets:{App}] Secret '{Name}' nicht geladen: {Error}",
_options.AppName, fullName, ex.Message);
}
}
if (synced.Count > 0)
{
await _cache.WriteAsync(synced);
_configRoot.Reload();
_logger.LogInformation("[OCORE.Secrets:{App}] Sync OK: {Count}/{Total}",
_options.AppName, synced.Count, _options.ManagedSecretKeys.Count);
}
else
{
_logger.LogWarning("[OCORE.Secrets:{App}] Sync: Keine Secrets geladen — Cache unverändert",
_options.AppName);
}
}
}
```
---
### 2.5 OCORE/Secrets/Extensions/PrefixKeyVaultSecretManager.cs
```csharp
using Azure.Extensions.AspNetCore.Configuration.Secrets;
using Azure.Security.KeyVault.Secrets;
namespace OCORE.Secrets.Extensions;
/// <summary>
/// Filtert beim Key Vault Laden nur Secrets mit dem App-Präfix
/// und konvertiert den Namen in einen IConfiguration-Key.
/// "crm--ConnectionStrings--Database" → "ConnectionStrings:Database"
/// </summary>
public class PrefixKeyVaultSecretManager : KeyVaultSecretManager
{
private readonly string _prefix;
public PrefixKeyVaultSecretManager(string appName)
=> _prefix = $"{appName}--";
public override bool Load(SecretProperties secret)
=> secret.Name.StartsWith(_prefix, StringComparison.OrdinalIgnoreCase);
public override string GetKey(KeyVaultSecret secret)
=> secret.Name[_prefix.Length..].Replace("--", ":");
}
```
---
### 2.6 OCORE/Secrets/Extensions/SecretManagementExtensions.cs
```csharp
using Azure.Identity;
using Azure.Security.KeyVault.Secrets;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
using OCORE.Secrets.Cache;
using OCORE.Secrets.Models;
using OCORE.Secrets.Sync;
namespace OCORE.Secrets.Extensions;
public static class SecretManagementExtensions
{
/// <summary>
/// Für Console Apps, Worker Services, Windows Services, WinForms/WPF.
/// Verwendung: Host.CreateDefaultBuilder(args).AddSecretManagement()
/// </summary>
public static IHostBuilder AddSecretManagement(this IHostBuilder hostBuilder)
{
hostBuilder.ConfigureAppConfiguration((context, config) =>
{
var built = config.Build();
var options = built.GetSection(AppSecretsOptions.SectionName).Get<AppSecretsOptions>()
?? new AppSecretsOptions();
if (!IsConfigured(options)) return;
var cacheFilePath = ResolveCachePath(
context.HostingEnvironment.ContentRootPath, options.CacheFilePath);
// 1. DPAPI-Cache (Fallback)
config.Add(new DpapiCacheConfigurationSource(cacheFilePath, options.AppName));
// 2. Key Vault (primär — überschreibt Cache wenn erreichbar)
TryAddKeyVault(config, options);
});
hostBuilder.ConfigureServices((context, services) =>
{
RegisterCoreServices(services, context.Configuration);
});
return hostBuilder;
}
/// <summary>
/// Gemeinsame Service-Registrierung — wird von OCORE und OCORE_web verwendet.
/// </summary>
public static void RegisterCoreServices(IServiceCollection services, IConfiguration configuration)
{
var options = configuration
.GetSection(AppSecretsOptions.SectionName)
.Get<AppSecretsOptions>() ?? new AppSecretsOptions();
if (!IsConfigured(options)) return;
services.Configure<AppSecretsOptions>(
configuration.GetSection(AppSecretsOptions.SectionName));
services.AddSingleton(new SecretClient(
new Uri(options.VaultUri),
new DefaultAzureCredential()));
services.AddSingleton<IDpapiSecretsCache>(sp =>
new DpapiSecretsCache(
ResolveCachePath(AppContext.BaseDirectory, options.CacheFilePath),
options.AppName,
sp.GetRequiredService<ILogger<DpapiSecretsCache>>()));
services.AddHostedService<KeyVaultSyncService>();
}
public static void TryAddKeyVault(IConfigurationBuilder config, AppSecretsOptions options)
{
try
{
config.AddAzureKeyVault(
new Uri(options.VaultUri),
new DefaultAzureCredential(),
new PrefixKeyVaultSecretManager(options.AppName));
}
catch (Exception ex)
{
Console.WriteLine($"[OCORE.Secrets] Key Vault nicht erreichbar: {ex.Message}");
Console.WriteLine("[OCORE.Secrets] Starte mit DPAPI-Cache");
}
}
public static bool IsConfigured(AppSecretsOptions options)
=> !string.IsNullOrEmpty(options.VaultUri) && !string.IsNullOrEmpty(options.AppName);
public static string ResolveCachePath(string basePath, string cacheFilePath)
=> Path.IsPathRooted(cacheFilePath)
? cacheFilePath
: Path.Combine(basePath, cacheFilePath);
}
```
---
## Teil 3: OCORE_web — Einzige neue Datei
OCORE_web referenziert OCORE bereits. Nur eine neue Datei hinzufügen:
```
OCORE_web/
... (bestehende Dateien)
Secrets/
SecretManagementWebExtensions.cs ← einzige neue Datei in OCORE_web
```
### 3.1 OCORE_web/Secrets/SecretManagementWebExtensions.cs
```csharp
using Microsoft.AspNetCore.Builder;
using Microsoft.Extensions.Configuration;
using OCORE.Secrets.Extensions;
using OCORE.Secrets.Models;
namespace OCORE_web.Secrets;
public static class SecretManagementWebExtensions
{
/// <summary>
/// Für ASP.NET Core Web Apps und Web APIs.
/// Verwendung: builder.AddSecretManagement()
/// </summary>
public static WebApplicationBuilder AddSecretManagement(
this WebApplicationBuilder builder)
{
var options = builder.Configuration
.GetSection(AppSecretsOptions.SectionName)
.Get<AppSecretsOptions>() ?? new AppSecretsOptions();
if (!SecretManagementExtensions.IsConfigured(options)) return builder;
var cacheFilePath = SecretManagementExtensions.ResolveCachePath(
builder.Environment.ContentRootPath, options.CacheFilePath);
// 1. DPAPI-Cache (Fallback)
builder.Configuration.Add(
new OCORE.Secrets.Cache.DpapiCacheConfigurationSource(cacheFilePath, options.AppName));
// 2. Key Vault (primär)
SecretManagementExtensions.TryAddKeyVault(builder.Configuration, options);
// 3. Services — aus OCORE (kein duplizierter Code)
SecretManagementExtensions.RegisterCoreServices(builder.Services, builder.Configuration);
return builder;
}
}
```
---
## Teil 4: Verwendung je App-Typ
### Web App (referenziert OCORE_web)
```csharp
// Program.cs
using OCORE_web.Secrets;
var builder = WebApplication.CreateBuilder(args);
builder.AddSecretManagement(); // ← aus OCORE_web
var app = builder.Build();
app.Run();
```
### Console App / Worker Service / Windows Service (referenziert OCORE)
```csharp
// Program.cs
using OCORE.Secrets.Extensions;
// Console / Worker
var host = Host.CreateDefaultBuilder(args)
.AddSecretManagement() // ← aus OCORE
.ConfigureServices(services =>
{
services.AddHostedService<MyWorker>();
})
.Build();
await host.RunAsync();
```
```csharp
// Windows Service — identisch, nur UseWindowsService() zusätzlich
var host = Host.CreateDefaultBuilder(args)
.UseWindowsService()
.AddSecretManagement() // ← aus OCORE
.ConfigureServices(services =>
{
services.AddHostedService<MyWindowsService>();
})
.Build();
await host.RunAsync();
```
### WinForms / WPF (referenziert OCORE)
```csharp
// Program.cs — Generic Host nachrüsten
using OCORE.Secrets.Extensions;
var host = Host.CreateDefaultBuilder()
.AddSecretManagement() // ← aus OCORE
.ConfigureServices(services =>
{
services.AddSingleton<MainForm>();
})
.Build();
Application.Run(host.Services.GetRequiredService<MainForm>());
```
---
## Teil 5: appsettings.json (identisch für alle App-Typen)
```json
{
"SecretManagement": {
"VaultUri": "https://company-vault.vault.azure.net/",
"AppName": "crm",
"CacheFilePath": "secrets.cache",
"SyncIntervalHours": 6,
"ManagedSecretKeys": [
"ConnectionStrings--Database",
"ExternalApi--ApiKey"
]
},
"ConnectionStrings": {
"Database": "MANAGED_BY_KEYVAULT"
}
}
```
> `AppName` ist der einzige Wert der sich zwischen den 15 Projekten unterscheidet.
---
## Teil 6: Key Vault Naming-Konvention
```
Format: {appname}--{ConfigSection}--{ConfigKey}
Web Apps:
crm--ConnectionStrings--Database
crm--ExternalApi--ApiKey
erp--ConnectionStrings--Database
erp--Smtp--Password
portal--Auth--JwtSecret
Non-Web Apps:
importer--ConnectionStrings--Database
scheduler--ConnectionStrings--Database
scheduler--Smtp--Password
```
### Naming-Regeln
- Nur alphanumerische Zeichen und einfache Bindestriche `-`
- Hierarchie-Trenner: `--` (doppelter Bindestrich)
- Kein Unterstrich, kein Punkt, kein Leerzeichen
- Maximal 127 Zeichen
---
## Teil 7: Einmalige Migration — Alle Apps
### 7.1 Inventur-Script
```powershell
# inventarisierung.ps1
param([string]$SolutionRoot = "C:\Projects\MySolution")
$patterns = @(
'password\s*=\s*["\'][^"\']+["\']',
'connectionstring\s*=\s*["\'][^"\']+["\']',
'apikey\s*=\s*["\'][^"\']+["\']',
'secret\s*=\s*["\'][^"\']+["\']',
'pwd\s*=\s*[^;]+'
)
Get-ChildItem -Path $SolutionRoot -Recurse -Include "*.json","*.config","*.cs" |
Where-Object { $_.FullName -notmatch '\\(bin|obj|\.git)\\' } |
ForEach-Object {
$file = $_
$content = Get-Content $file.FullName -Raw -ErrorAction SilentlyContinue
foreach ($pattern in $patterns) {
if ($content -match $pattern) {
Write-Host "⚠️ $($file.FullName)" -ForegroundColor Yellow
break
}
}
}
```
### 7.2 Migration Script
```powershell
# migrate-all-to-keyvault.ps1
param([Parameter(Mandatory)][string]$VaultName)
Connect-AzAccount
$allSecrets = @{
# Web Apps
"crm--ConnectionStrings--Database" = "Server=PROD-SQL;Database=CrmDb;..."
"crm--ExternalApi--ApiKey" = "crm-api-key"
"erp--ConnectionStrings--Database" = "Server=PROD-SQL;Database=ErpDb;..."
"erp--Smtp--Password" = "smtp-passwort"
"portal--ConnectionStrings--Database" = "Server=PROD-SQL;Database=PortalDb;..."
"portal--Auth--JwtSecret" = "jwt-secret"
# Non-Web Apps (OCORE)
"importer--ConnectionStrings--Database" = "Server=PROD-SQL;Database=ImportDb;..."
"scheduler--ConnectionStrings--Database" = "Server=PROD-SQL;Database=SchedDb;..."
"scheduler--Smtp--Password" = "smtp-passwort"
# Weitere Apps ergänzen...
}
$total = $allSecrets.Count
$done = 0
foreach ($name in $allSecrets.Keys) {
$value = ConvertTo-SecureString $allSecrets[$name] -AsPlainText -Force
Set-AzKeyVaultSecret -VaultName $VaultName -Name $name -SecretValue $value
$done++
Write-Progress -Activity "Migriere Secrets" -Status "$name" -PercentComplete (($done/$total)*100)
Write-Host "✅ [$done/$total] $name"
}
Write-Host "`nMigration abgeschlossen." -ForegroundColor Green
```
### 7.3 Bereinigung
```powershell
# bereinigung.ps1
$projektOrdner = @(
"C:\Projects\CRM",
"C:\Projects\ERP",
"C:\Projects\Portal",
"C:\Projects\Importer",
"C:\Projects\Scheduler"
# Alle Pfade eintragen
)
foreach ($ordner in $projektOrdner) {
$file = Join-Path $ordner "appsettings.json"
if (Test-Path $file) {
$json = Get-Content $file -Raw | ConvertFrom-Json
if ($json.ConnectionStrings) {
$json.ConnectionStrings.PSObject.Properties |
ForEach-Object { $_.Value = "MANAGED_BY_KEYVAULT" }
}
$json | ConvertTo-Json -Depth 10 | Set-Content $file
Write-Host "✅ Bereinigt: $file"
}
}
```
---
## Teil 8: Server-Setup (einmalig)
### 8.1 Managed Identity + Key Vault Zugriff
```powershell
# server-setup.ps1 — einmalig als Admin
# Managed Identity der VM abrufen
$identity = Get-AzVM -ResourceGroupName "MyRG" -Name "PROD-SERVER-01"
$objectId = $identity.Identity.PrincipalId
# Key Vault: Server darf lesen, nicht schreiben
Set-AzKeyVaultAccessPolicy `
-VaultName "company-vault" `
-ObjectId $objectId `
-PermissionsToSecrets Get, List
Write-Host "✅ Key Vault Zugriff konfiguriert für Object ID: $objectId"
```
### 8.2 Schreibrechte für Cache-Dateien
```powershell
# Für IIS Web Apps (App Pool User)
$webApps = @("CRM", "ERP", "Portal")
foreach ($pool in $webApps) {
$appPath = "C:\inetpub\$pool"
$user = "IIS AppPool\$pool"
$acl = Get-Acl $appPath
$rule = New-Object System.Security.AccessControl.FileSystemAccessRule(
$user, "Modify", "ContainerInherit,ObjectInherit", "None", "Allow")
$acl.SetAccessRule($rule)
Set-Acl -Path $appPath -AclObject $acl
Write-Host "✅ Web App Schreibrecht: $user → $appPath"
}
# Für Windows Services / Console Apps (LocalSystem oder dedizierter Service-User)
$serviceApps = @("Importer", "Scheduler")
foreach ($svc in $serviceApps) {
$appPath = "C:\services\$svc"
# LocalSystem hat standardmäßig Schreibrecht — nur prüfen ob Ordner existiert
if (-not (Test-Path $appPath)) {
New-Item -ItemType Directory -Path $appPath
Write-Host "✅ Service-Ordner erstellt: $appPath"
}
}
```
---
## Teil 9: .gitignore (Solution-Level)
```gitignore
# Secret Management
secrets.cache
**/secrets.cache
# Konfigurationsdateien mit echten Werten
appsettings.Production.json
appsettings.Staging.json
# Zertifikate
*.pfx
*.p12
*.key
```
---
## Teil 10: Pro-Projekt Checkliste
### Web Apps (OCORE_web)
```
[ ] 1. AppName festlegen (kurz, eindeutig, Kleinbuchstaben)
[ ] 2. OCORE_web bereits referenziert (transitiv: OCORE kommt mit)
[ ] 3. Program.cs:
using OCORE_web.Secrets;
builder.AddSecretManagement();
[ ] 4. appsettings.json: SecretManagement-Block mit korrektem AppName
[ ] 5. ManagedSecretKeys vollständig auflisten
[ ] 6. Secrets in Key Vault mit {appname}-- Präfix vorhanden
[ ] 7. Klartext-Secrets aus appsettings.json / web.config entfernt
[ ] 8. secrets.cache in .gitignore
[ ] 9. Logs nach Deploy prüfen:
"[OCORE.Secrets:crm] Sync OK" → ✅
"[OCORE.Secrets:crm] Key Vault nicht erreichbar" → ❌
```
### Non-Web Apps (OCORE)
```
[ ] 1. AppName festlegen (kurz, eindeutig, Kleinbuchstaben)
[ ] 2. OCORE bereits referenziert (vorhanden)
[ ] 3. Program.cs:
using OCORE.Secrets.Extensions;
Host.CreateDefaultBuilder(args).AddSecretManagement()
(Windows Service: zusätzlich .UseWindowsService())
[ ] 4. appsettings.json: SecretManagement-Block mit korrektem AppName
[ ] 5. ManagedSecretKeys vollständig auflisten
[ ] 6. Secrets in Key Vault mit {appname}-- Präfix vorhanden
[ ] 7. Klartext-Secrets aus Konfigurationsdateien entfernt
[ ] 8. secrets.cache in .gitignore
[ ] 9. Logs prüfen nach erstem Start
```
---
## Zusammenfassung der Artefakte
```
OCORE/Secrets/ ← Einmalig anlegen
Models/AppSecretsOptions.cs
Cache/DpapiSecretsCache.cs
Cache/DpapiCacheConfigurationSource.cs
Sync/KeyVaultSyncService.cs
Extensions/PrefixKeyVaultSecretManager.cs
Extensions/SecretManagementExtensions.cs ← IHostBuilder + shared helpers
OCORE_web/Secrets/ ← Einmalig anlegen (1 Datei)
SecretManagementWebExtensions.cs ← WebApplicationBuilder Extension
Pro Web-Projekt (15x oder subset):
Program.cs → +2 Zeilen (using + builder.AddSecretManagement())
appsettings.json → SecretManagement-Block, keine Secrets mehr
Pro Non-Web-Projekt:
Program.cs → +2 Zeilen (using + .AddSecretManagement())
appsettings.json → SecretManagement-Block, keine Secrets mehr
Scripts/
inventarisierung.ps1 ← Wo liegen aktuell Secrets?
migrate-all-to-keyvault.ps1 ← Einmalige Migration aller Apps
bereinigung.ps1 ← Platzhalter setzen nach Migration
server-setup.ps1 ← Managed Identity + ACLs (einmalig)
```
+16 -12
View File
@@ -32,14 +32,14 @@
<!-- Compatible packages (kept) --> <!-- Compatible packages (kept) -->
<PackageReference Include="BouncyCastle" Version="1.8.9" /> <PackageReference Include="BouncyCastle" Version="1.8.9" />
<PackageReference Include="HtmlAgilityPack" Version="1.13.0" /> <PackageReference Include="HtmlAgilityPack" Version="1.13.0" />
<PackageReference Include="MailKit" Version="4.17.0" /> <PackageReference Include="MailKit" Version="4.18.1" />
<PackageReference Include="Microsoft.IO.RecyclableMemoryStream" Version="3.0.1" /> <PackageReference Include="Microsoft.IO.RecyclableMemoryStream" Version="3.0.1" />
<PackageReference Include="OpenTelemetry.Exporter.OpenTelemetryProtocol" Version="1.18.0" /> <PackageReference Include="OpenTelemetry.Exporter.OpenTelemetryProtocol" Version="1.19.1" />
<PackageReference Include="OpenTelemetry.Extensions.Hosting" Version="1.18.0" /> <PackageReference Include="OpenTelemetry.Extensions.Hosting" Version="1.19.1" />
<PackageReference Include="OpenTelemetry.Instrumentation.AspNetCore" Version="1.18.0" /> <PackageReference Include="OpenTelemetry.Instrumentation.AspNetCore" Version="1.19.0" />
<PackageReference Include="OpenTelemetry.Instrumentation.Http" Version="1.18.0" /> <PackageReference Include="OpenTelemetry.Instrumentation.Http" Version="1.19.0" />
<PackageReference Include="OpenTelemetry.Instrumentation.Runtime" Version="1.18.0" /> <PackageReference Include="OpenTelemetry.Instrumentation.Runtime" Version="1.19.0" />
<PackageReference Include="OpenTelemetry.Instrumentation.SqlClient" Version="1.18.0" /> <PackageReference Include="OpenTelemetry.Instrumentation.SqlClient" Version="1.19.0" />
<PackageReference Include="Portable.BouncyCastle" Version="1.9.0" /> <PackageReference Include="Portable.BouncyCastle" Version="1.9.0" />
<PackageReference Include="QRCoder" Version="1.8.0" /> <PackageReference Include="QRCoder" Version="1.8.0" />
<PackageReference Include="PDFsharp" Version="6.2.4" /> <PackageReference Include="PDFsharp" Version="6.2.4" />
@@ -47,14 +47,14 @@
<PackageReference Include="Spire.PDF" Version="[8.10.5,8.10.5]" allowedVersions="[8.10.5,8.10.5]" /> <PackageReference Include="Spire.PDF" Version="[8.10.5,8.10.5]" allowedVersions="[8.10.5,8.10.5]" />
<!-- Updated packages --> <!-- Updated packages -->
<PackageReference Include="BouncyCastle.Cryptography" Version="2.7.0" /> <PackageReference Include="BouncyCastle.Cryptography" Version="2.7.0" />
<PackageReference Include="MimeKit" Version="4.17.0" /> <PackageReference Include="MimeKit" Version="4.18.1" />
<PackageReference Include="Newtonsoft.Json" Version="13.0.4" /> <PackageReference Include="Newtonsoft.Json" Version="13.0.4" />
<!-- New packages (needed for .NET 10) --> <!-- New packages (needed for .NET 10) -->
<PackageReference Include="Microsoft.Data.SqlClient" Version="7.0.2" /> <PackageReference Include="Microsoft.Data.SqlClient" Version="7.1.1" />
<PackageReference Include="SixLabors.ImageSharp" Version="4.1.1" /> <PackageReference Include="SixLabors.ImageSharp" Version="4.1.3" />
<PackageReference Include="System.Configuration.ConfigurationManager" Version="10.0.12" /> <PackageReference Include="System.Configuration.ConfigurationManager" Version="10.0.12" />
<PackageReference Include="System.Drawing.Common" Version="10.0.12" /> <PackageReference Include="System.Drawing.Common" Version="10.0.12" />
<PackageReference Include="Azure.Storage.Blobs" Version="12.29.2" /> <PackageReference Include="Azure.Storage.Blobs" Version="12.30.1" />
</ItemGroup> </ItemGroup>
<ItemGroup> <ItemGroup>
<Folder Include="App_Data\cache\" /> <Folder Include="App_Data\cache\" />
@@ -90,12 +90,16 @@
<!-- Zeitstempel im UTC Format --> <!-- Zeitstempel im UTC Format -->
<PropertyGroup> <PropertyGroup>
<_ZipStamp>$([System.DateTime]::UtcNow.ToString("yyyyMMdd_HHmmss"))</_ZipStamp> <_ZipStamp>$([System.DateTime]::UtcNow.ToString("yyyyMMdd_HHmmss"))</_ZipStamp>
<!-- Umgebungskennzeichnung fürs Zip: bevorzugt EnvironmentName aus dem Publish-Profil (z.B. "Development"),
sonst Fallback auf die Build-Configuration (z.B. "Debug"/"Release"). -->
<_ZipEnvironmentTag Condition="'$(EnvironmentName)' != ''">$(EnvironmentName)</_ZipEnvironmentTag>
<_ZipEnvironmentTag Condition="'$(_ZipEnvironmentTag)' == ''">$(Configuration)</_ZipEnvironmentTag>
<!-- Immer das aktuelle Publish-Ergebnis zippen (PublishDir/PubTmp\Out), nicht das Zielverzeichnis vom Profil --> <!-- Immer das aktuelle Publish-Ergebnis zippen (PublishDir/PubTmp\Out), nicht das Zielverzeichnis vom Profil -->
<_SourceDir>$([System.IO.Path]::GetFullPath('$(PublishDir)'))</_SourceDir> <_SourceDir>$([System.IO.Path]::GetFullPath('$(PublishDir)'))</_SourceDir>
<!-- ZIP nach Möglichkeit neben dem Zielordner aus dem Publish-Profil ablegen, sonst neben PublishDir --> <!-- ZIP nach Möglichkeit neben dem Zielordner aus dem Publish-Profil ablegen, sonst neben PublishDir -->
<_ZipOutputDir Condition="'$(PublishUrl)' != ''">$([System.IO.Path]::GetFullPath('$(PublishUrl)\..\'))</_ZipOutputDir> <_ZipOutputDir Condition="'$(PublishUrl)' != ''">$([System.IO.Path]::GetFullPath('$(PublishUrl)\..\'))</_ZipOutputDir>
<_ZipOutputDir Condition="'$(_ZipOutputDir)' == ''">$([System.IO.Path]::GetFullPath('$(_SourceDir)\..\'))</_ZipOutputDir> <_ZipOutputDir Condition="'$(_ZipOutputDir)' == ''">$([System.IO.Path]::GetFullPath('$(_SourceDir)\..\'))</_ZipOutputDir>
<_ZipFileName>$(MSBuildProjectName)-publish-$(_ZipStamp).zip</_ZipFileName> <_ZipFileName>$(MSBuildProjectName)-$(_ZipEnvironmentTag)-publish-$(_ZipStamp).zip</_ZipFileName>
<_ZipPath>$(_ZipOutputDir)$(_ZipFileName)</_ZipPath> <_ZipPath>$(_ZipOutputDir)$(_ZipFileName)</_ZipPath>
</PropertyGroup> </PropertyGroup>
<MakeDir Directories="$(_ZipOutputDir)" /> <MakeDir Directories="$(_ZipOutputDir)" />
@@ -0,0 +1,41 @@
using Fuchs.intranet;
namespace Fuchs.Logging;
/// <summary>
/// Pushes a per-request logging scope (request id, path, and — once cookie auth has populated
/// <see cref="HttpContext.User"/> — the authenticated user id) so every log line written while
/// handling this request, including ones written deep inside OCORE (SQL errors, PDF render
/// failures, ...), carries that attribution automatically. See ADR 0024: the scope flows into
/// every sink's serialized context (<c>LogEntry.dataSerialized</c>, via
/// <see cref="OCORE.MultiLogger.MultiLogger_base"/>'s context merging) and — for "authuser"
/// specifically — into the dedicated <see cref="OCORE.MultiLogger.LogEntry.AuthUser"/> column the
/// SQL sink writes to <c>fds__debuglog.AuthUser</c>.
/// Registered after <c>UseAuthentication()</c> so the user claim is already populated.
/// </summary>
public sealed class RequestLogScopeMiddleware
{
private readonly RequestDelegate _next;
public RequestLogScopeMiddleware(RequestDelegate next) => _next = next;
public async Task InvokeAsync(HttpContext context, ILogger<RequestLogScopeMiddleware> logger)
{
using (logger.BeginScope(BuildScope(context)))
await _next(context);
}
/// <summary>Pure so the scope shape is unit-testable without a live request pipeline.</summary>
internal static Dictionary<string, object> BuildScope(HttpContext context)
{
var scope = new Dictionary<string, object>
{
["requestId"] = context.TraceIdentifier,
["path"] = context.Request.Path.Value ?? "",
};
string? authUser = context.User?.FindFirst(FuchsUserIdentity.ClaimUserAccountId)?.Value;
if (!string.IsNullOrEmpty(authUser))
scope["authuser"] = authUser;
return scope;
}
}
+21
View File
@@ -51,9 +51,27 @@ public static class FuchsTelemetry
Meter.CreateCounter<long>("fuchs.blobstorage.uploads", "{upload}", "Number of documents successfully archived to Azure Blob Storage."); Meter.CreateCounter<long>("fuchs.blobstorage.uploads", "{upload}", "Number of documents successfully archived to Azure Blob Storage.");
public static readonly Counter<long> BlobUploadsFailed = public static readonly Counter<long> BlobUploadsFailed =
Meter.CreateCounter<long>("fuchs.blobstorage.uploads.failed", "{upload}", "Number of documents that failed to archive to Azure Blob Storage."); Meter.CreateCounter<long>("fuchs.blobstorage.uploads.failed", "{upload}", "Number of documents that failed to archive to Azure Blob Storage.");
public static readonly Counter<long> BlobBackupCopiesSucceeded =
Meter.CreateCounter<long>("fuchs.blobstorage.backupcopies", "{copy}",
"Number of archived blobs successfully copied to a backup name (admin invoice-file backup/clear).");
public static readonly Counter<long> BlobBackupCopiesFailed =
Meter.CreateCounter<long>("fuchs.blobstorage.backupcopies.failed", "{copy}",
"Number of blob backup-copy attempts that failed.");
public static readonly Counter<long> InvoicesBackdated =
Meter.CreateCounter<long>("fuchs.invoices.backdated", "{invoice}",
"Invoice finalisations with the admin backdate option (ADR 0032), tagged by outcome.");
public static readonly Counter<long> InvoiceFileBackups =
Meter.CreateCounter<long>("fuchs.invoice.filebackups", "{backup}",
"Admin-triggered invoice file backup+clear actions (Do_Process_Admin backupclearinvoicefile), tagged by outcome.");
public static readonly Counter<long> SystemProbes = public static readonly Counter<long> SystemProbes =
Meter.CreateCounter<long>("fuchs.systemstatus.probes", "{probe}", Meter.CreateCounter<long>("fuchs.systemstatus.probes", "{probe}",
"Admin system-status connectivity probes executed, tagged by component and outcome."); "Admin system-status connectivity probes executed, tagged by component and outcome.");
public static readonly Counter<long> InvoiceInlineImageFetches =
Meter.CreateCounter<long>("fuchs.invoice.inlineimages.fetches", "{fetch}",
"Invoice-text image fetches for inline PDF rendering (ADR 0018), tagged by outcome.");
public static readonly Counter<long> PdfPreviews =
Meter.CreateCounter<long>("fuchs.pdf.previews", "{preview}",
"PDF preview requests (draft/invoice/reminder/report image previews), tagged by endpoint and outcome.");
// ── Performance histograms (durations in milliseconds) ─────────────────── // ── Performance histograms (durations in milliseconds) ───────────────────
public static readonly Histogram<double> PdfRenderDuration = public static readonly Histogram<double> PdfRenderDuration =
@@ -62,6 +80,9 @@ public static class FuchsTelemetry
Meter.CreateHistogram<double>("fuchs.report.render.duration", "ms", "Report render duration."); Meter.CreateHistogram<double>("fuchs.report.render.duration", "ms", "Report render duration.");
public static readonly Histogram<double> EmailSendDuration = public static readonly Histogram<double> EmailSendDuration =
Meter.CreateHistogram<double>("fuchs.email.send.duration", "ms", "Email send round-trip duration."); Meter.CreateHistogram<double>("fuchs.email.send.duration", "ms", "Email send round-trip duration.");
public static readonly Histogram<double> InvoiceInlineImagesResolveDuration =
Meter.CreateHistogram<double>("fuchs.invoice.inlineimages.resolve.duration", "ms",
"Total wall-clock time to fetch an invoice's inline images (all images in parallel).");
/// <summary>Starts a span on the application's <see cref="ActivitySource"/>.</summary> /// <summary>Starts a span on the application's <see cref="ActivitySource"/>.</summary>
public static Activity? StartActivity(string name, ActivityKind kind = ActivityKind.Internal) => public static Activity? StartActivity(string name, ActivityKind kind = ActivityKind.Internal) =>
+78 -2
View File
@@ -8,8 +8,11 @@ using Microsoft.AspNetCore.Authentication.Cookies;
using Microsoft.AspNetCore.StaticFiles; using Microsoft.AspNetCore.StaticFiles;
using Microsoft.Extensions.FileProviders; using Microsoft.Extensions.FileProviders;
using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using OCORE.MultiLogger;
using OCORE.security; using OCORE.security;
using OCORE.web; using OCORE.web;
using OCORE.web.pwmailer;
using OpenTelemetry.Metrics; using OpenTelemetry.Metrics;
using OpenTelemetry.Resources; using OpenTelemetry.Resources;
using OpenTelemetry.Trace; using OpenTelemetry.Trace;
@@ -53,6 +56,26 @@ public class Program
// Initialize the Fuchs intranet singleton with configuration // Initialize the Fuchs intranet singleton with configuration
FuchsOcmsIntranet.Initialize(builder.Configuration); FuchsOcmsIntranet.Initialize(builder.Configuration);
// SQL log sink (Warning+, ADR 0024): every _logger.LogWarning/-Error/-Critical call — not
// just the explicit ones that used to call Fuchs_intranet.debug_log — additionally lands in
// [dbo].[fds__debuglog] via [dbo].[fds__admin_logdebug_ext], with the failing SQL/context
// (redacted), the ILogger category, and — via RequestLogScopeMiddleware's scope — the
// request id and authenticated user, all in dedicated/serialized columns. The file sink
// above (AddFuchsLogging) stays the always-on baseline; this is the durable, queryable copy
// support actually reads from. Config switch (default on) allows disabling per environment.
if (builder.Configuration.GetValue("Fuchs:Logging:SqlSink:Enabled", true))
{
builder.Logging.AddSqlLogger();
builder.Services.Configure<MultiLoggerOptions>(o =>
{
o.SQL.ConnectionString = FuchsOcmsIntranet.Instance.Intranet__SQLConnectionString;
o.SQL.StoredProcedure = "dbo.fds__admin_logdebug_ext";
o.SQL.LogLevel = LogLevel.Warning;
o.SQL.TruncateTextAt = 4000;
o.SQL.IncludeCategory = true; // fds__admin_logdebug_ext declares @category
});
}
// Initialize FdsConfig so FdsMfr / FdsMfrClient can resolve connection strings // Initialize FdsConfig so FdsMfr / FdsMfrClient can resolve connection strings
fds.FdsConfig.Initialize(builder.Configuration); fds.FdsConfig.Initialize(builder.Configuration);
@@ -115,8 +138,22 @@ public class Program
builder.Services.AddAuthorization(); builder.Services.AddAuthorization();
builder.Services.AddHttpContextAccessor(); builder.Services.AddHttpContextAccessor();
// Communication service (email + SMS via ProcessWeb Mailer API) // Communication service (email + SMS via ProcessWeb Mailer API). Transport is the OCORE_web
// PWMailer client (ADR 0029); its options are mapped from the existing Fuchs:Mailer section
// rather than a second config section, so the Key Vault-managed Fuchs:Mailer:Token keeps working
// (secret Fuchs--Mailer--Token also named as the on-demand fallback for the placeholder case).
builder.Services.Configure<ProcessWebComSettings>(builder.Configuration.GetSection("Fuchs:Mailer")); builder.Services.Configure<ProcessWebComSettings>(builder.Configuration.GetSection("Fuchs:Mailer"));
builder.Services.AddOptions<PWMailerClientOptions>()
.Configure<IOptions<ProcessWebComSettings>>((o, s) =>
{
o.BaseUrl = s.Value.BaseUrl.TrimEnd('/') + "/api/mailer";
o.Account = s.Value.AccountId;
o.Token = s.Value.Token;
o.TokenSecretName = "Fuchs--Mailer--Token";
o.ServerId = s.Value.ServerId;
o.UserAgent = "Fuchs Intranet";
});
builder.Services.AddPWMailerClient<FuchsMailerClient>();
// Dev/test safety net: Fuchs:Email:OverrideRecipient redirects every outbound email // Dev/test safety net: Fuchs:Email:OverrideRecipient redirects every outbound email
// (see appsettings.Development.json) so real tenant-owners/end-customers are never emailed. // (see appsettings.Development.json) so real tenant-owners/end-customers are never emailed.
builder.Services.Configure<FuchsEmailSettings>(builder.Configuration.GetSection("Fuchs:Email")); builder.Services.Configure<FuchsEmailSettings>(builder.Configuration.GetSection("Fuchs:Email"));
@@ -127,7 +164,10 @@ public class Program
builder.Services.AddSingleton<IERechnungValidator, ProcessWebERechnungValidator>(); // external EN16931 + PDF/A-3 check builder.Services.AddSingleton<IERechnungValidator, ProcessWebERechnungValidator>(); // external EN16931 + PDF/A-3 check
builder.Services.AddHttpClient(ProcessWebERechnungValidator.HttpClientName, builder.Services.AddHttpClient(ProcessWebERechnungValidator.HttpClientName,
c => c.Timeout = TimeSpan.FromSeconds(90)); c => c.Timeout = TimeSpan.FromSeconds(90));
builder.Services.AddHttpClient("ProcessWebMailer"); // Fetches invoice-editor images (mfr:// picture links) for inline PDF rendering; whitelist-
// restricted to the mfr portal (see InvoiceService.AllowedInlineImagePrefix / ADR 0018).
builder.Services.AddHttpClient(InvoiceService.InlineImageHttpClientName,
c => c.Timeout = TimeSpan.FromSeconds(10));
builder.Services.AddScoped<IComService, ProcessWebComService>(); builder.Services.AddScoped<IComService, ProcessWebComService>();
// Holds the one-shot startup self-test result for the lifetime of the process so the Admin // Holds the one-shot startup self-test result for the lifetime of the process so the Admin
// module can display it as a non-refreshable widget (must be registered before the service). // module can display it as a non-refreshable widget (must be registered before the service).
@@ -141,6 +181,7 @@ public class Program
builder.Services.AddScoped<IWidgetService, FuchsWidgetService>(); builder.Services.AddScoped<IWidgetService, FuchsWidgetService>();
builder.Services.AddScoped<IReportService, FuchsReportService>(); builder.Services.AddScoped<IReportService, FuchsReportService>();
builder.Services.AddScoped<IInvoiceService, InvoiceService>(); builder.Services.AddScoped<IInvoiceService, InvoiceService>();
builder.Services.AddScoped<IInvoiceFileBackupService, InvoiceFileBackupService>();
builder.Services.AddScoped<IReminderService, ReminderService>(); builder.Services.AddScoped<IReminderService, ReminderService>();
builder.Services.AddScoped<IEventService, EventService>(); builder.Services.AddScoped<IEventService, EventService>();
@@ -211,6 +252,38 @@ public class Program
// calls silently no-op instead of reaching Debug output / ErrorLog.txt. // calls silently no-op instead of reaching Debug output / ErrorLog.txt.
OCORE.Logging.Logger = app.Logger; OCORE.Logging.Logger = app.Logger;
// Exceptions that never pass through a request (Do()'s catch-all, PdfPreviewAsync,
// UseExceptionHandler) or a hosted service's own try/catch — a faulted fire-and-forget
// Task nobody awaited, or a crash on a non-request thread — are otherwise visible only
// as a debugger notification or a process exit: they never reach ILogger and therefore
// never reach fds__debuglog (ADR 0024). Route them through the app logger so the SQL
// sink and ErrorLog.txt both see them. On a terminating crash the fire-and-forget SQL
// write may not complete; the synchronous file sink (0003) is the record then.
TaskScheduler.UnobservedTaskException += (_, e) =>
{
app.Logger.LogError(new EventId(0, "Unobserved task exception"), e.Exception,
"Unobserved task exception: {Message}", e.Exception.GetBaseException().Message);
e.SetObserved();
};
AppDomain.CurrentDomain.UnhandledException += (_, e) =>
{
var ex = e.ExceptionObject as Exception;
app.Logger.LogCritical(new EventId(0, "Unhandled exception"), ex,
"Unhandled exception on a non-request thread (terminating={IsTerminating}): {Message}",
e.IsTerminating, ex?.Message ?? e.ExceptionObject?.ToString());
};
// One deliberate Critical entry per application start. Critical is the one level no
// provider filters (file sink, console, SQL sink at Warning+), so a fds__debuglog row
// with CodeReference 'Application started' proves on every restart that the whole
// log pipeline (ADR 0024) is working end to end — and its absence is itself the alarm.
// Registered on ApplicationStarted so it is written once the host and every sink are up.
app.Lifetime.ApplicationStarted.Register(() =>
app.Logger.LogCritical(new EventId(0, "Application started"),
"Fuchs started: version={Version} environment={Environment} machine={Machine} pid={Pid}",
typeof(Program).Assembly.GetName().Version?.ToString() ?? "?",
app.Environment.EnvironmentName, Environment.MachineName, Environment.ProcessId));
if (!app.Environment.IsDevelopment()) if (!app.Environment.IsDevelopment())
{ {
app.UseExceptionHandler("/error"); app.UseExceptionHandler("/error");
@@ -234,6 +307,9 @@ public class Program
app.UseRouting(); app.UseRouting();
app.UseAuthentication(); app.UseAuthentication();
// After authentication (needs the user claim) and before authorization/dispatch — see
// RequestLogScopeMiddleware for what this attributes every log line in the request to.
app.UseMiddleware<RequestLogScopeMiddleware>();
app.UseAuthorization(); app.UseAuthorization();
app.MapHub<NotificationHub>("/notifications"); app.MapHub<NotificationHub>("/notifications");
app.MapHub<DraftPreviewHub>("/draftpreview"); app.MapHub<DraftPreviewHub>("/draftpreview");
@@ -0,0 +1,21 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- https://go.microsoft.com/fwlink/?LinkID=208121. -->
<Project>
<PropertyGroup>
<DeleteExistingFiles>true</DeleteExistingFiles>
<ExcludeApp_Data>false</ExcludeApp_Data>
<LaunchSiteAfterPublish>true</LaunchSiteAfterPublish>
<LastUsedBuildConfiguration>Debug</LastUsedBuildConfiguration>
<LastUsedPlatform>x64</LastUsedPlatform>
<PublishProvider>FileSystem</PublishProvider>
<PublishUrl>Q:\PWProjects\Fuchs_Intranet_Dev</PublishUrl>
<WebPublishMethod>FileSystem</WebPublishMethod>
<_TargetId>Folder</_TargetId>
<SiteUrlToLaunchAfterPublish />
<TargetFramework>net10.0</TargetFramework>
<RuntimeIdentifier>win-x64</RuntimeIdentifier>
<ProjectGuid>2856176d-cda6-1be2-0ce0-d72c26fb4f35</ProjectGuid>
<SelfContained>false</SelfContained>
<EnvironmentName>Development</EnvironmentName>
</PropertyGroup>
</Project>
@@ -16,5 +16,6 @@
<RuntimeIdentifier>win-x64</RuntimeIdentifier> <RuntimeIdentifier>win-x64</RuntimeIdentifier>
<ProjectGuid>2856176d-cda6-1be2-0ce0-d72c26fb4f35</ProjectGuid> <ProjectGuid>2856176d-cda6-1be2-0ce0-d72c26fb4f35</ProjectGuid>
<SelfContained>false</SelfContained> <SelfContained>false</SelfContained>
<EnvironmentName>Production</EnvironmentName>
</PropertyGroup> </PropertyGroup>
</Project> </Project>
+65
View File
@@ -68,6 +68,60 @@ public class AzureBlobStorageService : IBlobStorageService
byte[] content, IReadOnlyDictionary<string, object?>? sourceRow = null, CancellationToken cancellationToken = default) byte[] content, IReadOnlyDictionary<string, object?>? sourceRow = null, CancellationToken cancellationToken = default)
=> UploadAsync(category, containerName, documentId, fileName, content, sourceRow, cancellationToken); => UploadAsync(category, containerName, documentId, fileName, content, sourceRow, cancellationToken);
public Task<Uri?> CopyInvoicePdfToBackupAsync(string invoiceId, string fileName,
CancellationToken cancellationToken = default)
=> CopyToBackupAsync("invoice", _settings.InvoiceContainer, invoiceId, fileName, cancellationToken);
private async Task<Uri?> CopyToBackupAsync(string category, string containerName, string documentId,
string fileName, CancellationToken cancellationToken)
{
if (_client == null) return null;
string blobName = BuildBlobName(documentId, fileName);
using var act = FuchsTelemetry.StartActivity("blobstorage.copytobackup");
act?.SetTag("fuchs.blobstorage.category", category);
act?.SetTag("fuchs.blobstorage.id", documentId);
try
{
var containerClient = _client.GetBlobContainerClient(containerName);
var sourceBlob = containerClient.GetBlobClient(blobName);
Response<bool> exists = await sourceBlob.ExistsAsync(cancellationToken);
if (!exists.Value)
{
_logger.LogDebug(
"Blob backup-copy skipped for {Category} {Id} — no existing blob at {Blob}.",
category, documentId, blobName);
return null;
}
string backupBlobFileName = string.IsNullOrWhiteSpace(fileName) ? $"{documentId}.pdf" : fileName;
string backupName = $"{documentId}/backup_{DateTime.UtcNow:yyyyMMdd_HHmmss}_{backupBlobFileName}";
var backupBlob = containerClient.GetBlobClient(backupName);
// Server-side copy: the bytes never pass through this process. A private source in the
// same storage account is authorised by the request's own Shared Key credential, so no
// SAS is needed. WaitForCompletionAsync throws if the copy ends in a failed/aborted state.
// The original is deliberately left in place — it stays the "current" archive until the
// next regeneration overwrites it, and nothing can be half-renamed.
CopyFromUriOperation copy = await backupBlob.StartCopyFromUriAsync(
sourceBlob.Uri, (BlobCopyFromUriOptions?)null, cancellationToken);
await copy.WaitForCompletionAsync(cancellationToken);
FuchsTelemetry.BlobBackupCopiesSucceeded.Add(1, new KeyValuePair<string, object?>("category", category));
_logger.LogInformation("Copied {Category} {Id} blob {Old} to backup {New}.",
category, documentId, blobName, backupName);
return backupBlob.Uri;
}
catch (Exception ex)
{
FuchsTelemetry.BlobBackupCopiesFailed.Add(1, new KeyValuePair<string, object?>("category", category));
act?.SetStatus(ActivityStatusCode.Error, ex.Message);
_logger.LogError(ex, "Blob backup-copy failed for {Category} {Id} ({Blob}).",
category, documentId, blobName);
return null;
}
}
public async Task<bool> ExistsAsync(string containerName, string documentId, string fileName, public async Task<bool> ExistsAsync(string containerName, string documentId, string fileName,
CancellationToken cancellationToken = default) CancellationToken cancellationToken = default)
{ {
@@ -81,6 +135,12 @@ public class AzureBlobStorageService : IBlobStorageService
Response<bool> response = await blobClient.ExistsAsync(cancellationToken); Response<bool> response = await blobClient.ExistsAsync(cancellationToken);
return response.Value; return response.Value;
} }
catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
{
// Caller cancelled (e.g. app shutdown during the startup backfill) — not a storage failure;
// reporting "not archived" here made the backfill go on to a doomed upload logged as an error.
throw;
}
catch (Exception ex) catch (Exception ex)
{ {
_logger.LogWarning(ex, "Blob existence check failed for {Container}/{Blob} — treating as not archived.", _logger.LogWarning(ex, "Blob existence check failed for {Container}/{Blob} — treating as not archived.",
@@ -216,6 +276,11 @@ public class AzureBlobStorageService : IBlobStorageService
category, documentId, containerName, blobName); category, documentId, containerName, blobName);
return blobClient.Uri; return blobClient.Uri;
} }
catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
{
_logger.LogDebug("Blob upload cancelled for {Category} {Id} (caller cancelled).", category, documentId);
throw;
}
catch (Exception ex) catch (Exception ex)
{ {
FuchsTelemetry.BlobUploadsFailed.Add(1, new KeyValuePair<string, object?>("category", category)); FuchsTelemetry.BlobUploadsFailed.Add(1, new KeyValuePair<string, object?>("category", category));
+13
View File
@@ -0,0 +1,13 @@
namespace Fuchs.Services;
/// <summary>
/// Outcome of a draft flush ("Zwischenspeichern", <see cref="IInvoiceDraftService.FlushToDbAsync"/> /
/// <see cref="IReminderDraftService.FlushToDbAsync"/>). The draft service alone decides whether a
/// flush creates a DB row or updates the one an earlier flush created — flushes of one session are
/// serialised, so a save that overlaps the first registration of a new draft becomes an update — and
/// reports that decision here so the caller publishes the matching domain event instead of guessing
/// from the session state it saw before the flush.
/// </summary>
/// <param name="Data">The registered data; its <c>Id</c> is empty when the registration failed.</param>
/// <param name="Updated"><c>true</c> when the registration ran as an update of an existing DB draft, <c>false</c> for a create.</param>
public sealed record DraftFlushResult<T>(T Data, bool Updated) where T : class;
+1 -7
View File
@@ -259,13 +259,7 @@ public static class ERechnungMapper
} }
// ── helpers ──────────────────────────────────────────────────────────────── // ── helpers ────────────────────────────────────────────────────────────────
private static DateOnly IssueDate(GenericObjectDictionary? reg) private static DateOnly IssueDate(GenericObjectDictionary? reg) => InvoiceDates.IssueDate(reg);
{
foreach (var key in new[] { "DateFinalized", "DateCreated" })
if (reg?.getString(key) is { Length: > 0 } s && DateTime.TryParse(s, CultureInfo.InvariantCulture, DateTimeStyles.None, out var dt))
return DateOnly.FromDateTime(dt);
return DateOnly.FromDateTime(DateTime.Today);
}
/// <summary>Normalises a raw country value (ISO alpha-2, or a German/English name) to a country code.</summary> /// <summary>Normalises a raw country value (ISO alpha-2, or a German/English name) to a country code.</summary>
internal static CountryCode NormalizeCountry(string? raw) internal static CountryCode NormalizeCountry(string? raw)
+34
View File
@@ -0,0 +1,34 @@
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using OCORE.web;
using OCORE.web.pwmailer;
namespace Fuchs.Services;
/// <summary>
/// The OCORE_web <see cref="PWMailerClientService"/> with Fuchs' pre-validation: on top of the
/// contract rules the base applies, every outbound message is checked against the Mailer's
/// attachment limits (<see cref="MailerAttachmentPolicy"/>, configured under
/// <c>Fuchs:Mailer:Attachments</c>) before any bytes are sent. Registered as the application's
/// <see cref="IPWMailerClientService"/> in <c>Program.cs</c>; options are mapped from
/// <c>Fuchs:Mailer</c> (<see cref="ProcessWebComSettings"/>) there.
/// </summary>
public class FuchsMailerClient : PWMailerClientService
{
private readonly MailerAttachmentLimits _limits;
public FuchsMailerClient(IOptions<PWMailerClientOptions> options, IOptions<ProcessWebComSettings> settings,
IHttpClientFactory httpClientFactory, ILogger<PWMailerClientService> logger,
IOCORE_web_securityservice? securityService = null)
: base(options, httpClientFactory, logger, securityService)
{
_limits = settings.Value.Attachments;
}
protected override IReadOnlyList<string> ValidateMessage(PWMailerMessage message)
{
var problems = new List<string>(base.ValidateMessage(message));
problems.AddRange(MailerAttachmentPolicy.Validate(message.Attachments, _limits));
return problems;
}
}
+2 -2
View File
@@ -107,10 +107,10 @@ public class FuchsPdfService : IPdfService
} }
public void ApplyInvoice(Document doc, FuchsPdf.FdsTextBlocks textBlocks, public void ApplyInvoice(Document doc, FuchsPdf.FdsTextBlocks textBlocks,
FdsInvoiceData invoice, bool draft = false) FdsInvoiceData invoice, bool draft = false, IReadOnlyDictionary<string, string>? inlineImages = null)
{ {
_logger.LogDebug("ApplyInvoice id={Id} draft={Draft}", invoice.Id, draft); _logger.LogDebug("ApplyInvoice id={Id} draft={Draft}", invoice.Id, draft);
FuchsPdf.ApplyInvoice(doc, textBlocks, invoice, draft); FuchsPdf.ApplyInvoice(doc, textBlocks, invoice, draft, inlineImages);
} }
public void ApplyReminder(Document doc, FuchsPdf.FdsTextBlocks textBlocks, public void ApplyReminder(Document doc, FuchsPdf.FdsTextBlocks textBlocks,
+14 -2
View File
@@ -12,7 +12,8 @@ public interface IBlobStorageService
/// supplied (typically <c>FdsInvoiceData.InvoiceRegistration</c>), blob metadata is projected from /// supplied (typically <c>FdsInvoiceData.InvoiceRegistration</c>), blob metadata is projected from
/// it using <see cref="AzureBlobStorageSettings.MetadataFields"/> — see <see cref="DocumentMetadataBuilder"/>. /// it using <see cref="AzureBlobStorageSettings.MetadataFields"/> — see <see cref="DocumentMetadataBuilder"/>.
/// Returns the blob URI, or <c>null</c> when storage is disabled/unconfigured /// Returns the blob URI, or <c>null</c> when storage is disabled/unconfigured
/// or the upload failed — failures never break the primary DB-storage flow. /// or the upload failed — failures never break the primary DB-storage flow. Only a cancelled
/// <paramref name="cancellationToken"/> surfaces, as <see cref="OperationCanceledException"/>.
/// </summary> /// </summary>
Task<Uri?> UploadInvoicePdfAsync(string invoiceId, string fileName, byte[] content, Task<Uri?> UploadInvoicePdfAsync(string invoiceId, string fileName, byte[] content,
IReadOnlyDictionary<string, object?>? sourceRow = null, CancellationToken cancellationToken = default); IReadOnlyDictionary<string, object?>? sourceRow = null, CancellationToken cancellationToken = default);
@@ -37,10 +38,21 @@ public interface IBlobStorageService
Task<Uri?> UploadDocumentAsync(string category, string containerName, string documentId, string fileName, Task<Uri?> UploadDocumentAsync(string category, string containerName, string documentId, string fileName,
byte[] content, IReadOnlyDictionary<string, object?>? sourceRow = null, CancellationToken cancellationToken = default); byte[] content, IReadOnlyDictionary<string, object?>? sourceRow = null, CancellationToken cancellationToken = default);
/// <summary>
/// Server-side copies an existing invoice PDF blob to a timestamped backup name in the same
/// container; the original blob is left in place. Called before an invoice's stored file is
/// cleared: the next regenerate-on-read (ADR 0025) uploads under the original blob name with
/// <c>overwrite: true</c>, and the backup copy is what survives that. Returns the backup
/// blob's URI, or <c>null</c> when there was nothing to back up, storage is disabled/unconfigured,
/// or the copy failed — logged, never throws.
/// </summary>
Task<Uri?> CopyInvoicePdfToBackupAsync(string invoiceId, string fileName, CancellationToken cancellationToken = default);
/// <summary> /// <summary>
/// Returns <c>true</c> if a blob already exists for the given container/document/filename /// Returns <c>true</c> if a blob already exists for the given container/document/filename
/// combination. Used by the startup backfill to skip documents that were already archived. /// combination. Used by the startup backfill to skip documents that were already archived.
/// Returns <c>false</c> (never throws) when storage is disabled/unconfigured or the check fails. /// Returns <c>false</c> when storage is disabled/unconfigured or the check fails; throws only
/// <see cref="OperationCanceledException"/> when <paramref name="cancellationToken"/> is cancelled.
/// </summary> /// </summary>
Task<bool> ExistsAsync(string containerName, string documentId, string fileName, Task<bool> ExistsAsync(string containerName, string documentId, string fileName,
CancellationToken cancellationToken = default); CancellationToken cancellationToken = default);
+37 -3
View File
@@ -2,7 +2,9 @@
/// <summary> /// <summary>
/// Abstraction for outbound communication: email and SMS. /// Abstraction for outbound communication: email and SMS.
/// Backed by the ProcessWeb Mailer API (POST {BaseUrl}/mailer/push_com). /// Backed by the ProcessWeb Mailer API (POST {BaseUrl}/api/mailer/push_com); contract at
/// https://processweb.de/api/mailer/fc, client-side description in
/// Docs/Concepts/processweb-mailer-interface.md.
/// </summary> /// </summary>
public interface IComService public interface IComService
{ {
@@ -14,9 +16,15 @@ public interface IComService
/// <param name="html">HTML body content (signature is appended automatically).</param> /// <param name="html">HTML body content (signature is appended automatically).</param>
/// <param name="email">Recipient email address.</param> /// <param name="email">Recipient email address.</param>
/// <param name="name">Recipient display name.</param> /// <param name="name">Recipient display name.</param>
/// <param name="attachments">Optional file attachments (filename → content).</param> /// <param name="attachments">
/// Optional file attachments (filename → content). Validated against the Mailer's limits
/// (<see cref="MailerAttachmentPolicy"/>) before sending; any violation fails the send.
/// </param>
/// <param name="cc">Optional Cc recipient (e.g. a customer's "Zusatzempfänger").</param> /// <param name="cc">Optional Cc recipient (e.g. a customer's "Zusatzempfänger").</param>
/// <returns><c>true</c> when the email was accepted successfully.</returns> /// <returns>
/// <c>true</c> only when the Mailer accepted the message <em>and</em> confirmed that every
/// attachment was processed (<c>attachmentsProcessed</c> equals the number sent).
/// </returns>
Task<bool> SendEmailAsync(string reference, string subject, string html, Task<bool> SendEmailAsync(string reference, string subject, string html,
string email, string name, Dictionary<string, byte[]>? attachments = null, string? cc = null); string email, string name, Dictionary<string, byte[]>? attachments = null, string? cc = null);
@@ -27,4 +35,30 @@ public interface IComService
/// <param name="message">Text message body.</param> /// <param name="message">Text message body.</param>
/// <returns><c>true</c> when the SMS was accepted successfully.</returns> /// <returns><c>true</c> when the SMS was accepted successfully.</returns>
Task<bool> SendSmsAsync(string mobile, string message); Task<bool> SendSmsAsync(string mobile, string message);
/// <summary>
/// Verifies the configured account/token against the Mailer API's credential-check
/// endpoint (GET {BaseUrl}/api/mailer/verify) without sending anything.
/// </summary>
Task<MailerCredentialsCheckResult> VerifyCredentialsAsync(CancellationToken cancellationToken = default);
}
/// <summary>Outcome of a Mailer API credential verification (see <see cref="IComService.VerifyCredentialsAsync"/>).</summary>
public sealed class MailerCredentialsCheckResult
{
/// <summary><c>true</c> only when the Mailer API confirmed the credentials (HTTP 200, <c>ok:true</c>).</summary>
public bool Ok { get; init; }
/// <summary>Account name the Mailer API echoed back, when verified.</summary>
public string? Account { get; init; }
/// <summary>
/// Server ID the Mailer API echoed back. Only present when a <c>serverid</c> was sent with
/// the request (i.e. <see cref="ProcessWebComSettings.ServerId"/> is configured); in that
/// case <see cref="Ok"/> is only <c>true</c> when this server belongs to the account too.
/// </summary>
public string? ServerId { get; init; }
/// <summary>Human-readable failure detail (status code / body / exception message), never the token.</summary>
public string? Detail { get; init; }
} }
+7 -4
View File
@@ -45,13 +45,16 @@ public interface IInvoiceDraftService
/// <summary> /// <summary>
/// Persists the cached session to the DB via the existing invoice registration path /// Persists the cached session to the DB via the existing invoice registration path
/// ("Zwischenspeichern"). Sets <see cref="InvoiceDraftSession.InvId"/> on success. /// ("Zwischenspeichern"). Sets <see cref="InvoiceDraftSession.InvId"/> on success. Flushes of one
/// Returns the registered invoice data (for the success event), or null if the token is unknown. /// session are serialised (<see cref="InvoiceDraftSession.FlushGate"/>): an overlapping save of a
/// not-yet-registered draft waits for the first registration and then runs as an update, so a
/// double-click never creates two DB rows. Returns the registered data plus whether the flush
/// ran as an update (for the success event), or null if the token is unknown.
/// </summary> /// </summary>
Task<FdsInvoiceData?> FlushToDbAsync(string token, string userAccountId, DatabaseSecurity dbSec); Task<DraftFlushResult<FdsInvoiceData>?> FlushToDbAsync(string token, string userAccountId, DatabaseSecurity dbSec);
/// <summary>Renders a draft PDF straight from the cached session (no client upload). Null if token unknown.</summary> /// <summary>Renders a draft PDF straight from the cached session (no client upload). Null if token unknown.</summary>
Document? RenderPreview(string token); Task<Document?> RenderPreviewAsync(string token);
/// <summary>Removes the session from the cache (explicit close/discard/finalise). Returns true if one was present.</summary> /// <summary>Removes the session from the cache (explicit close/discard/finalise). Returns true if one was present.</summary>
bool Close(string token); bool Close(string token);

Some files were not shown because too many files have changed in this diff Show More