Add AD auth and B1 currency fields

This commit is contained in:
2026-04-29 11:07:35 +02:00
parent 3ac03a4782
commit 4a1561d85f
29 changed files with 1016 additions and 31 deletions
@@ -1,5 +1,6 @@
@inherits LayoutComponentBase @inherits LayoutComponentBase
@implements IDisposable @implements IDisposable
@using System.Security.Claims
@inject TrafagSalesExporter.Services.IUiTextService UiText @inject TrafagSalesExporter.Services.IUiTextService UiText
<MudThemeProvider Theme="_theme" /> <MudThemeProvider Theme="_theme" />
@@ -23,6 +24,11 @@
<MudSelectItem Value="@("de")">DE</MudSelectItem> <MudSelectItem Value="@("de")">DE</MudSelectItem>
<MudSelectItem Value="@("en")">EN</MudSelectItem> <MudSelectItem Value="@("en")">EN</MudSelectItem>
</MudSelect> </MudSelect>
<AuthorizeView>
<Authorized Context="authState">
<MudText Typo="Typo.caption" Class="mr-3">@ShortName(authState.User)</MudText>
</Authorized>
</AuthorizeView>
<img src="trafag.jpg" alt="Trafag" class="app-logo" /> <img src="trafag.jpg" alt="Trafag" class="app-logo" />
</MudAppBar> </MudAppBar>
@@ -67,6 +73,13 @@
private string T(string german, string english) => UiText.Text(german, english); private string T(string german, string english) => UiText.Text(german, english);
private static string ShortName(ClaimsPrincipal user)
{
var name = user.Identity?.Name ?? string.Empty;
var separator = name.LastIndexOf('\\');
return separator >= 0 && separator < name.Length - 1 ? name[(separator + 1)..] : name;
}
public void Dispose() public void Dispose()
{ {
UiText.Changed -= HandleLanguageChanged; UiText.Changed -= HandleLanguageChanged;
@@ -1,21 +1,30 @@
@using TrafagSalesExporter.Security
@inject TrafagSalesExporter.Services.IUiTextService UiText @inject TrafagSalesExporter.Services.IUiTextService UiText
<MudNavMenu> <MudNavMenu>
<MudNavLink Href="/" Match="NavLinkMatch.All" Icon="@Icons.Material.Filled.Dashboard"> <MudNavLink Href="/" Match="NavLinkMatch.All" Icon="@Icons.Material.Filled.Dashboard">
@T("Dashboard", "Dashboard") @T("Dashboard", "Dashboard")
</MudNavLink> </MudNavLink>
<AuthorizeView Policy="@SecurityPolicies.AdminOnly">
<Authorized>
<MudNavLink Href="/standorte" Match="NavLinkMatch.Prefix" Icon="@Icons.Material.Filled.LocationOn"> <MudNavLink Href="/standorte" Match="NavLinkMatch.Prefix" Icon="@Icons.Material.Filled.LocationOn">
@T("Standorte", "Sites") @T("Standorte", "Sites")
</MudNavLink> </MudNavLink>
<MudNavLink Href="/transformations" Match="NavLinkMatch.Prefix" Icon="@Icons.Material.Filled.Transform"> <MudNavLink Href="/transformations" Match="NavLinkMatch.Prefix" Icon="@Icons.Material.Filled.Transform">
@T("Transformationen", "Transformations") @T("Transformationen", "Transformations")
</MudNavLink> </MudNavLink>
</Authorized>
</AuthorizeView>
<MudNavLink Href="/management-cockpit" Match="NavLinkMatch.Prefix" Icon="@Icons.Material.Filled.Analytics"> <MudNavLink Href="/management-cockpit" Match="NavLinkMatch.Prefix" Icon="@Icons.Material.Filled.Analytics">
@T("Management Cockpit", "Management Cockpit") @T("Management Cockpit", "Management Cockpit")
</MudNavLink> </MudNavLink>
<AuthorizeView Policy="@SecurityPolicies.AdminOnly">
<Authorized>
<MudNavLink Href="/settings" Match="NavLinkMatch.Prefix" Icon="@Icons.Material.Filled.Settings"> <MudNavLink Href="/settings" Match="NavLinkMatch.Prefix" Icon="@Icons.Material.Filled.Settings">
@T("Settings", "Settings") @T("Settings", "Settings")
</MudNavLink> </MudNavLink>
</Authorized>
</AuthorizeView>
<MudNavLink Href="/logs" Match="NavLinkMatch.Prefix" Icon="@Icons.Material.Filled.List"> <MudNavLink Href="/logs" Match="NavLinkMatch.Prefix" Icon="@Icons.Material.Filled.List">
@T("Logs", "Logs") @T("Logs", "Logs")
</MudNavLink> </MudNavLink>
@@ -1,4 +1,5 @@
@page "/settings" @page "/settings"
@attribute [Authorize(Policy = TrafagSalesExporter.Security.SecurityPolicies.AdminOnly)]
@using TrafagSalesExporter.Models @using TrafagSalesExporter.Models
@using TrafagSalesExporter.Services @using TrafagSalesExporter.Services
@inject ISettingsPageService SettingsPageActions @inject ISettingsPageService SettingsPageActions
@@ -1,4 +1,5 @@
@page "/standorte" @page "/standorte"
@attribute [Authorize(Policy = TrafagSalesExporter.Security.SecurityPolicies.AdminOnly)]
@using Microsoft.AspNetCore.Components.Forms @using Microsoft.AspNetCore.Components.Forms
@using System.Text.Json @using System.Text.Json
@using System.Reflection @using System.Reflection
@@ -1,4 +1,5 @@
@page "/transformations" @page "/transformations"
@attribute [Authorize(Policy = TrafagSalesExporter.Security.SecurityPolicies.AdminOnly)]
@using System.Reflection @using System.Reflection
@using TrafagSalesExporter.Models @using TrafagSalesExporter.Models
@using TrafagSalesExporter.Services @using TrafagSalesExporter.Services
+20 -3
View File
@@ -1,6 +1,23 @@
<Router AppAssembly="typeof(Program).Assembly"> @using Microsoft.AspNetCore.Components.Authorization
<CascadingAuthenticationState>
<Router AppAssembly="typeof(Program).Assembly">
<Found Context="routeData"> <Found Context="routeData">
<RouteView RouteData="routeData" DefaultLayout="typeof(Layout.MainLayout)" /> <AuthorizeRouteView RouteData="routeData" DefaultLayout="typeof(Layout.MainLayout)">
<NotAuthorized>
<LayoutView Layout="typeof(Layout.MainLayout)">
<MudAlert Severity="Severity.Error" Variant="Variant.Outlined">
Zugriff verweigert. Bitte mit einem berechtigten Windows-/Domain-Benutzer anmelden.
</MudAlert>
</LayoutView>
</NotAuthorized>
<Authorizing>
<LayoutView Layout="typeof(Layout.MainLayout)">
<MudProgressCircular Indeterminate="true" />
</LayoutView>
</Authorizing>
</AuthorizeRouteView>
<FocusOnNavigate RouteData="routeData" Selector="h1" /> <FocusOnNavigate RouteData="routeData" Selector="h1" />
</Found> </Found>
</Router> </Router>
</CascadingAuthenticationState>
@@ -1,7 +1,9 @@
@using System.Net.Http @using System.Net.Http
@using Microsoft.AspNetCore.Components.Forms @using Microsoft.AspNetCore.Components.Forms
@using Microsoft.AspNetCore.Components.Authorization
@using Microsoft.AspNetCore.Components.Routing @using Microsoft.AspNetCore.Components.Routing
@using Microsoft.AspNetCore.Components.Web @using Microsoft.AspNetCore.Components.Web
@using Microsoft.AspNetCore.Authorization
@using Microsoft.JSInterop @using Microsoft.JSInterop
@using MudBlazor @using MudBlazor
@using TrafagSalesExporter.Components @using TrafagSalesExporter.Components
+165
View File
@@ -2,6 +2,171 @@
Stand: 2026-04-15 Stand: 2026-04-15
## Nachtrag 2026-04-29 B1-Belegwaehrungsfelder aus HANA
Der HANA/B1-Export wurde um Beleg- und Hauswaehrungsfelder erweitert.
Grund:
- `p.StockPrice` muss fachlich in der B1-Hauswaehrung bewertet werden
- die Hauswaehrung kommt aus `OADM.MainCurncy`
- bisher wurde `StandardCostCurrency` aus `p.Currency` bzw. `h.DocCur` abgeleitet
- fuer Power-BI-/Cockpit-Gegenpruefung muessen Belegwaehrung, Hauswaehrung, Netto-/Steuerbetraege und Kurs sichtbar sein
Neue Felder in `SalesRecord` und `CentralSalesRecord`:
- `DocumentCurrency`
- `DocumentTotalForeignCurrency`
- `DocumentTotalLocalCurrency`
- `VatSumForeignCurrency`
- `VatSumLocalCurrency`
- `DocumentRate`
- `CompanyCurrency`
B1-Feldmapping:
- `DocumentCurrency` = `OINV/ORIN.DocCur`
- `DocumentTotalForeignCurrency` = `OINV/ORIN.DocTotalFC`
- `DocumentTotalLocalCurrency` = `OINV/ORIN.DocTotal`
- `VatSumForeignCurrency` = `OINV/ORIN.VatSumFC`
- `VatSumLocalCurrency` = `OINV/ORIN.VatSum`
- `DocumentRate` = `OINV/ORIN.DocRate`
- `CompanyCurrency` = `OADM.MainCurncy`
- `StandardCostCurrency` = `OADM.MainCurncy`
Technische Umsetzung:
- `HanaQueryService` liest `OADM` jetzt per `CROSS JOIN`
- Invoice- und Credit-Note-Query liefern die neuen Felder
- bei Gutschriften werden Dokument- und Steuerbetraege mit negativem Vorzeichen uebernommen
- `CentralSalesRecords`-Schema wurde erweitert
- bestehende SQLite-DBs erhalten die neuen Spalten per `DatabaseSchemaMaintenanceService`
- `CentralSalesRecordService` persistiert und liest die neuen Felder
- `ExcelExportService` schreibt die neuen Spalten in Standort- und `Sales_All_*.xlsx`-Dateien
- `ManualExcelImportService` kann die neuen Spalten wieder einlesen
- `ConfigTransferService` erhaelt die neuen Felder beim Remapping zentraler Laufzeitdaten
Wichtig fuer Power BI:
- die neuen `DocumentTotal*`- und `VatSum*`-Felder sind Belegkopfwerte
- sie werden in der positionsbasierten Excel pro Positionszeile wiederholt
- diese Felder duerfen daher nicht blind positionsweise summiert werden
- fuer Belegkopfsummen in Power BI zuerst nach `DocumentType`, `Invoice Number`, `TSC` und ggf. `Land` deduplizieren
- positionsbasierte Auswertungen sollen weiterhin mit positionsbezogenen Feldern wie `Sales Price/Value`, `Quantity` oder `Standard cost` arbeiten
Verifikation:
- `dotnet build .\TrafagSalesExporter.csproj --verbosity minimal` erfolgreich
- `dotnet test .\TrafagSalesExporter.Tests\TrafagSalesExporter.Tests.csproj --verbosity minimal` erfolgreich
- `48/48` Tests gruen
- `ManualExcelImportServiceTests` pruefen die neuen Excel-Spalten
- `CentralSalesRecordServiceTests` pruefen Persistenz und Ruecklesen der neuen B1-Felder
## Nachtrag 2026-04-29 Clean-Code-/DI-Befund
Der aktuelle Code ist DI-orientiert und deutlich besser strukturiert als zu Beginn des Refactorings, aber noch nicht durchgehend ein Clean-Code-Ideal.
Positiv:
- Services werden weitgehend ueber Interfaces und DI verdrahtet
- `DataSourceAdapter` trennt die Quellsysteme
- Page-Services reduzieren direkte DB-Logik in mehreren Razor-Seiten
- `Scoped` fuer UI-nahe Services und `Singleton` fuer gemeinsame Infrastruktur/Orchestrierung ist bewusst gewaehlt
- Testabdeckung fuer zentrale Fachlogik ist vorhanden und waechst
Weiterhin offene Clean-Code-Risiken:
- `DatabaseInitializationService` ist weiterhin produktiver Reparatur-/Migrationspfad
- `Settings.razor` und `Standorte.razor` enthalten noch viel Workflow-/UI-Logik
- `ManagementCockpitService` und `ConfigTransferService` sind breit und sollten spaeter weiter aufgeteilt werden
- Retry-/Robustheitslayer fuer externe Systeme fehlt
- Secret-Store fehlt
- Auth-Rollenmodell ist aktuell pragmatisch, aber noch grob
Bewertung:
- Architektur: brauchbar bis gut
- DI: grundsaetzlich sauber
- Clean Code: mittel bis gut, mit klaren Altlasten
Dieser Befund wurde bewusst nur dokumentiert. Die strukturelle Bereinigung wird spaeter priorisiert.
## Nachtrag 2026-04-29 Authentifizierung / AD-Zugriffsschutz
Nach Rueckmeldung der IT wurde ein Zugriffsschutz fuer die Blazor-App eingebaut.
Vorher konnte jeder Benutzer mit Netzwerkzugriff auf die App-URL die Anwendung oeffnen. Das war kritisch, weil die App Verkaufsdaten, Standort-/Quellsystemkonfiguration, SharePoint-Konfiguration, Config Import/Export und Secrets bzw. Zugangsdatenfelder beruehrt.
Neuer Stand:
- die App ist grundsaetzlich authentifizierungspflichtig
- produktives Ziel ist Windows Authentication / Active Directory
- Berechtigungen laufen ueber AD-Gruppen
- es gibt keine eigene Benutzer-/Passwortverwaltung in der App
- es gibt keinen versteckten produktiven Backdoor
Neue Security-Dateien:
- `Security/SecurityOptions.cs`
- `Security/SecurityPolicies.cs`
- `Security/DevelopmentAuthenticationHandler.cs`
Geaenderte zentrale Dateien:
- `Program.cs`
- `Components/Routes.razor`
- `Components/_Imports.razor`
- `Components/Layout/NavMenu.razor`
- `Components/Layout/MainLayout.razor`
- `appsettings.json`
- `appsettings.Development.json`
Aktuelles Rollenmodell:
- `Security:AccessGroups` steuert Zugriff auf die App
- `Security:AdminGroups` steuert Admin-Berechtigung
- Default-Gruppen sind `TRAFAG\\TrafagSalesExporter-Users` und `TRAFAG\\TrafagSalesExporter-Admins`
- echte Gruppennamen muessen von der IT bestaetigt oder angepasst werden
Admin-geschuetzte Seiten:
- `Settings`
- `Standorte`
- `Transformations`
Dashboard, Management Cockpit und Logs bleiben fuer berechtigte angemeldete Benutzer sichtbar.
Development:
- `appsettings.Development.json` aktiviert bei `ASPNETCORE_ENVIRONMENT=Development` einen lokalen Development-Auth-Handler
- Default-User: `DEV\\TrafagDeveloper`
- `DevelopmentUserIsAdmin=true`, damit lokal weiter programmiert werden kann
- produktiv darf die App nicht mit `Development` laufen
IIS-/IT-Hinweise:
1. Windows Authentication aktivieren
2. Anonymous Authentication deaktivieren
3. `ASPNETCORE_ENVIRONMENT` produktiv nicht auf `Development` setzen
4. AD-Gruppen fuer Benutzer und Admins anlegen oder bestehende Gruppen eintragen
5. `Security:AccessGroups` und `Security:AdminGroups` in produktiver Konfiguration korrekt setzen
Verifikation:
```text
dotnet build .\TrafagSalesExporter.csproj --verbosity minimal
dotnet test .\TrafagSalesExporter.Tests\TrafagSalesExporter.Tests.csproj --verbosity minimal
```
Ergebnis:
- Build erfolgreich
- Tests erfolgreich
- `48/48` Tests gruen
- Auth-Policy-Tests fuer AccessGroup, AdminGroup und Development-Admin vorhanden
- lokaler Development-Auth-Start geprueft: `http://localhost:55416` antwortet mit HTTP `200`
- bekannte MudBlazor-Analyzer-Warnungen zu `Dense` bleiben
## Nachtrag 2026-04-29 Management-Cockpit-Auswertung ## Nachtrag 2026-04-29 Management-Cockpit-Auswertung
Seit dem letzten dokumentierten Stand vom 2026-04-17 wurde das `Management Cockpit` weiter ausgebaut. Dieser Abschnitt rekonstruiert den aktuellen Stand aus dem Code, weil die Aenderungen nach einem PC-Absturz nicht direkt nachdokumentiert wurden. Seit dem letzten dokumentierten Stand vom 2026-04-17 wurde das `Management Cockpit` weiter ausgebaut. Dieser Abschnitt rekonstruiert den aktuellen Stand aus dem Code, weil die Aenderungen nach einem PC-Absturz nicht direkt nachdokumentiert wurden.
+84
View File
@@ -25,6 +25,7 @@ Zielbild:
## Technologie-Stack ## Technologie-Stack
- UI: Blazor Server + MudBlazor - UI: Blazor Server + MudBlazor
- Authentifizierung: ASP.NET Core Authentication/Authorization, produktiv Windows Authentication / Active Directory
- Datenbank: SQLite (`trafag_exporter.db`) - Datenbank: SQLite (`trafag_exporter.db`)
- Excel lesen/schreiben: ClosedXML - Excel lesen/schreiben: ClosedXML
- SAP HANA Zugriff: `Sap.Data.Hana.Core.v2.1.dll` - SAP HANA Zugriff: `Sap.Data.Hana.Core.v2.1.dll`
@@ -42,6 +43,14 @@ Wichtige Dateien:
`Program.cs` registriert fast die komplette Architektur ueber DI und fuehrt beim Start `DatabaseInitializationService.InitializeAsync()` aus. `Program.cs` registriert fast die komplette Architektur ueber DI und fuehrt beim Start `DatabaseInitializationService.InitializeAsync()` aus.
Zusaetzlich registriert `Program.cs` den Zugriffsschutz:
- `AddCascadingAuthenticationState`
- Windows Authentication fuer produktive Umgebungen
- Development-Authentication-Handler nur bei `ASPNETCORE_ENVIRONMENT=Development` und `Security:DevelopmentBypass=true`
- globale Fallback-Policy fuer authentifizierte/berechtigte User
- Policy `AdminOnly` fuer administrative Seiten
## Hauptseiten ## Hauptseiten
Navigation: Navigation:
@@ -71,6 +80,13 @@ Kurzrollen:
- `Settings`: SharePoint, Exportpfade, Quellsysteme, Wechselkurse, Config Import/Export - `Settings`: SharePoint, Exportpfade, Quellsysteme, Wechselkurse, Config Import/Export
- `Logs`: technische Ereignisprotokolle - `Logs`: technische Ereignisprotokolle
Security:
- alle Routen erfordern Authentifizierung
- `Settings`, `Standorte` und `Transformations` sind `AdminOnly`
- Admin-Navigation wird nur fuer Admins angezeigt
- eingeloggter Benutzer wird im App-Bar angezeigt
## Kernmodelle ## Kernmodelle
Wichtige Entity-Klassen: Wichtige Entity-Klassen:
@@ -90,6 +106,18 @@ Wichtige Entity-Klassen:
- [Models/AppEventLog.cs](C:/Users/koi/source/repos/Ai/TrafagSalesExporter/Models/AppEventLog.cs) - [Models/AppEventLog.cs](C:/Users/koi/source/repos/Ai/TrafagSalesExporter/Models/AppEventLog.cs)
- [Models/CurrencyExchangeRate.cs](C:/Users/koi/source/repos/Ai/TrafagSalesExporter/Models/CurrencyExchangeRate.cs) - [Models/CurrencyExchangeRate.cs](C:/Users/koi/source/repos/Ai/TrafagSalesExporter/Models/CurrencyExchangeRate.cs)
`SalesRecord` / `CentralSalesRecord` enthalten neben den positionsnahen Feldern auch B1-Belegwaehrungsfelder:
- `DocumentCurrency` aus `DocCur`
- `DocumentTotalForeignCurrency` aus `DocTotalFC`
- `DocumentTotalLocalCurrency` aus `DocTotal`
- `VatSumForeignCurrency` aus `VatSumFC`
- `VatSumLocalCurrency` aus `VatSum`
- `DocumentRate` aus `DocRate`
- `CompanyCurrency` aus `OADM.MainCurncy`
Wichtig: diese Dokumentwerte sind Belegkopfwerte und werden in der positionsbasierten Excel pro Position wiederholt. Fuer Belegkopfsummen muessen Auswertungen nach Beleg deduplizieren.
Wichtige Relationen: Wichtige Relationen:
- `Site -> HanaServer` optional - `Site -> HanaServer` optional
@@ -403,6 +431,49 @@ Bereits gehaertete Fehlerbilder:
- Legacy-Credential-Spalten in `HanaServers` - Legacy-Credential-Spalten in `HanaServers`
- verschobene Spalten im `Sites_old -> Sites`-Kopierpfad - verschobene Spalten im `Sites_old -> Sites`-Kopierpfad
## Authentifizierung / Autorisierung
Dateien:
- [Security/SecurityOptions.cs](C:/Users/koi/source/repos/Ai/TrafagSalesExporter/Security/SecurityOptions.cs)
- [Security/SecurityPolicies.cs](C:/Users/koi/source/repos/Ai/TrafagSalesExporter/Security/SecurityPolicies.cs)
- [Security/DevelopmentAuthenticationHandler.cs](C:/Users/koi/source/repos/Ai/TrafagSalesExporter/Security/DevelopmentAuthenticationHandler.cs)
- [Components/Routes.razor](C:/Users/koi/source/repos/Ai/TrafagSalesExporter/Components/Routes.razor)
- [Components/Layout/NavMenu.razor](C:/Users/koi/source/repos/Ai/TrafagSalesExporter/Components/Layout/NavMenu.razor)
- [Components/Layout/MainLayout.razor](C:/Users/koi/source/repos/Ai/TrafagSalesExporter/Components/Layout/MainLayout.razor)
Produktives Ziel:
- Windows Authentication / Active Directory
- keine eigene Benutzerverwaltung
- Zugriff ueber AD-Gruppen
- Adminrechte ueber separate AD-Gruppe
Konfiguration in `appsettings.json`:
- `Security:AccessGroups`
- `Security:AdminGroups`
- `Security:DevelopmentBypass`
- `Security:DevelopmentUserIsAdmin`
- `Security:DevelopmentUserName`
Default-Gruppen:
- `TRAFAG\\TrafagSalesExporter-Users`
- `TRAFAG\\TrafagSalesExporter-Admins`
Development:
- `appsettings.Development.json` aktiviert einen lokalen Development-Auth-Handler
- dieser ist nur fuer lokale Entwicklung gedacht
- produktiv darf `ASPNETCORE_ENVIRONMENT` nicht `Development` sein
IIS-Betrieb:
- Windows Authentication aktivieren
- Anonymous Authentication deaktivieren
- AD-Gruppennamen in produktiver Konfiguration setzen
## Config Import / Export ## Config Import / Export
Dateien: Dateien:
@@ -462,6 +533,19 @@ Aktuell vorhandene Schwerpunkte:
- Mengen-Auswertung ohne Waehrungsumrechnung - Mengen-Auswertung ohne Waehrungsumrechnung
- Zusatz-Summenfelder in Zeitreihen - Zusatz-Summenfelder in Zeitreihen
`SecurityPolicyFactoryTests` decken inzwischen ab:
- App-Zugriff fuer User in `AccessGroups`
- Ablehnung fuer User ausserhalb der Access-Gruppen
- Development-Auth-Zugriff im lokalen Modus
- Admin-Zugriff fuer User in `AdminGroups`
- Ablehnung normaler User fuer `AdminOnly`
- Development-Admin-Claim
`CentralSalesRecordServiceTests` decken inzwischen ab:
- Persistenz und Ruecklesen der B1-Belegwaehrungsfelder in `CentralSalesRecords`
Wichtig: Wichtig:
- es gibt aktuell keine echten UI-Komponententests mit `bUnit` - es gibt aktuell keine echten UI-Komponententests mit `bUnit`
@@ -32,6 +32,13 @@ public class CentralSalesRecord
public string PurchaseOrderNumber { get; set; } = string.Empty; public string PurchaseOrderNumber { get; set; } = string.Empty;
public decimal SalesPriceValue { get; set; } public decimal SalesPriceValue { get; set; }
public string SalesCurrency { get; set; } = string.Empty; public string SalesCurrency { get; set; } = string.Empty;
public string DocumentCurrency { get; set; } = string.Empty;
public decimal DocumentTotalForeignCurrency { get; set; }
public decimal DocumentTotalLocalCurrency { get; set; }
public decimal VatSumForeignCurrency { get; set; }
public decimal VatSumLocalCurrency { get; set; }
public decimal DocumentRate { get; set; }
public string CompanyCurrency { get; set; } = string.Empty;
public string Incoterms2020 { get; set; } = string.Empty; public string Incoterms2020 { get; set; } = string.Empty;
public string SalesResponsibleEmployee { get; set; } = string.Empty; public string SalesResponsibleEmployee { get; set; } = string.Empty;
public DateTime? InvoiceDate { get; set; } public DateTime? InvoiceDate { get; set; }
@@ -22,6 +22,13 @@ public class SalesRecord
public string PurchaseOrderNumber { get; set; } = string.Empty; public string PurchaseOrderNumber { get; set; } = string.Empty;
public decimal SalesPriceValue { get; set; } public decimal SalesPriceValue { get; set; }
public string SalesCurrency { get; set; } = string.Empty; public string SalesCurrency { get; set; } = string.Empty;
public string DocumentCurrency { get; set; } = string.Empty;
public decimal DocumentTotalForeignCurrency { get; set; }
public decimal DocumentTotalLocalCurrency { get; set; }
public decimal VatSumForeignCurrency { get; set; }
public decimal VatSumLocalCurrency { get; set; }
public decimal DocumentRate { get; set; }
public string CompanyCurrency { get; set; } = string.Empty;
public string Incoterms2020 { get; set; } = string.Empty; public string Incoterms2020 { get; set; } = string.Empty;
public string SalesResponsibleEmployee { get; set; } = string.Empty; public string SalesResponsibleEmployee { get; set; } = string.Empty;
public DateTime? InvoiceDate { get; set; } public DateTime? InvoiceDate { get; set; }
@@ -2,6 +2,183 @@
Stand: 2026-04-15 Stand: 2026-04-15
## Nachtrag 2026-04-29 B1-Belegwaehrungsfelder
Der HANA/B1-Export zieht jetzt zusaetzliche Belegwaehrungsfelder:
- `DocCur`
- `DocTotalFC`
- `DocTotal`
- `VatSumFC`
- `VatSum`
- `DocRate`
- `OADM.MainCurncy`
Neue Zielfelder:
- `DocumentCurrency`
- `DocumentTotalForeignCurrency`
- `DocumentTotalLocalCurrency`
- `VatSumForeignCurrency`
- `VatSumLocalCurrency`
- `DocumentRate`
- `CompanyCurrency`
Zusaetzlich gilt jetzt:
- `StandardCostCurrency` kommt im HANA-Pfad aus `OADM.MainCurncy`
- `Sales_All_*.xlsx` enthaelt die neuen Spalten
- `CentralSalesRecords` enthaelt die neuen Spalten
- bestehende SQLite-DBs werden beim Start um die Spalten erweitert
- Manual-Excel-Import kann die neuen Spalten lesen
### Wichtig fuer Auswertungen
Die neuen `DocumentTotal*`- und `VatSum*`-Werte sind Belegkopfwerte und werden in der positionsbasierten Datei pro Position wiederholt.
Power BI:
- nicht positionsweise summieren
- zuerst nach Beleg deduplizieren, z. B. `TSC` + `DocumentType` + `Invoice Number`
- danach Belegkopfwerte summieren
Positionswerte wie `Sales Price/Value`, `Quantity` und `Standard cost` bleiben fuer positionsbasierte Summen geeignet.
### Verifikation
Geprueft:
```text
dotnet build .\TrafagSalesExporter.csproj --verbosity minimal
dotnet test .\TrafagSalesExporter.Tests\TrafagSalesExporter.Tests.csproj --verbosity minimal
```
Ergebnis:
- Build erfolgreich
- Tests erfolgreich
- `48/48` Tests gruen
## Nachtrag 2026-04-29 Clean-Code-/DI-Befund
Aktueller Architektur- und DI-Zustand nach den letzten Umbauten:
Gesamturteil:
- die App ist deutlich besser strukturiert als zu Beginn
- die Grundarchitektur ist brauchbar bis gut und fuer pragmatischen produktiven Einsatz geeignet
- Dependency Injection wird grundsaetzlich sinnvoll genutzt
- Clean Code ist mittel bis gut, aber noch nicht durchgehend konsequent
Was positiv ist:
- Kernservices laufen weitgehend ueber Interfaces und DI
- `DataSourceAdapter`-Pattern trennt `HANA`, `SAP_GATEWAY` und `MANUAL_EXCEL`
- `SiteExportService` ist dadurch deutlich schlanker als frueher
- UI-nahe Page-Services wurden eingefuehrt
- viele Razor-Seiten sind nicht mehr direkt `DbContext`-lastig
- `Scoped` fuer Page-Services und `Singleton` fuer gemeinsame Infrastruktur/Orchestrierung ist bewusst gewaehlt
- Tests decken wichtige Fachlogik ab, u. a. Transformationen, ConfigTransfer, DatabaseInitialization und ManagementCockpit
Was noch nicht ideal ist:
- `DatabaseInitializationService` bleibt ein produktiver Reparatur-/Migrationsblock und ist kein sauberes versioniertes Migrationssystem
- `Settings.razor` und `Standorte.razor` enthalten weiterhin relativ viel UI-/Workflow-Logik
- `ManagementCockpitService`, `ConfigTransferService` und Teile der Initialisierung sind noch sehr breit
- konsolidierter Export hat historisch noch Semantikreste zwischen Live-Snapshot und `CentralSalesRecords`
- Secrets/Zugangsdaten sind noch nicht ideal geloest
- zentraler Retry-/Resilience-Layer fuer SAP/HANA/SharePoint fehlt
- Auth ist jetzt pragmatisch mit User/Admin geschnitten, aber noch nicht fein nach `Viewer`, `Exporter`, `Admin`, `Finance`
Sinnvolle spaetere Clean-Code-Schritte:
1. `ManagementCockpitService` in kleinere Query-, Aggregation- und Currency-Komponenten teilen
2. `Settings.razor` und `Standorte.razor` weiter Richtung Page-/Application-Services entlasten
3. `DatabaseInitializationService` langfristig durch versionierte Migrationen ersetzen
4. Auth-Policies fachlich feiner schneiden, z. B. `Viewer`, `Exporter`, `Admin`, `Finance`
5. Retry/Timeout/Failure-Handling fuer externe Systeme zentralisieren
6. Secret-Store-Konzept umsetzen
## Nachtrag 2026-04-29 Authentifizierung / AD
Die App wurde nach IT-Rueckmeldung gegen anonymen Zugriff abgesichert.
Neuer Stand:
- globale Authentifizierungspflicht
- produktiv vorgesehen: Windows Authentication / Active Directory
- Zugriff und Adminrechte ueber AD-Gruppen
- kein eigener App-Login
- kein versteckter produktiver Backdoor
- lokaler Development-Bypass nur bei `ASPNETCORE_ENVIRONMENT=Development`
Neue/angepasste Dateien:
- `Program.cs`
- `Security/SecurityOptions.cs`
- `Security/SecurityPolicies.cs`
- `Security/DevelopmentAuthenticationHandler.cs`
- `Components/Routes.razor`
- `Components/_Imports.razor`
- `Components/Layout/NavMenu.razor`
- `Components/Layout/MainLayout.razor`
- `Components/Pages/Settings.razor`
- `Components/Pages/Standorte.razor`
- `Components/Pages/Transformations.razor`
- `appsettings.json`
- `appsettings.Development.json`
Aktuelle Default-Gruppen:
- `TRAFAG\TrafagSalesExporter-Users`
- `TRAFAG\TrafagSalesExporter-Admins`
### Noch mit IT zu klaeren
1. Exakte AD-Domain-/Gruppennamen bestaetigen
2. AD-Gruppen anlegen oder bestehende Gruppen verwenden
3. IIS-Zielumgebung festlegen
4. Auf IIS Windows Authentication aktivieren
5. Auf IIS Anonymous Authentication deaktivieren
6. Sicherstellen, dass produktiv nicht `ASPNETCORE_ENVIRONMENT=Development` gesetzt ist
7. Test mit einem normalen User und einem Admin-User durchfuehren
### Fachliche Rollenentscheidung
Aktuell:
- Admin:
- `Settings`
- `Standorte`
- `Transformations`
- berechtigter User:
- Dashboard
- Management Cockpit
- Logs
Noch zu entscheiden:
- ob `Logs` ebenfalls Admin-only sein soll
- ob Export-Buttons im Dashboard nur fuer eine eigene Rolle `Exporter` sichtbar sein sollen
- ob Management Cockpit fuer alle berechtigten User oder nur fuer Management/Finance-Gruppen sichtbar sein soll
### Verifikation
Geprueft:
```text
dotnet build .\TrafagSalesExporter.csproj --verbosity minimal
dotnet test .\TrafagSalesExporter.Tests\TrafagSalesExporter.Tests.csproj --verbosity minimal
```
Ergebnis:
- Build erfolgreich
- Tests erfolgreich
- `48/48` Tests gruen
- Auth-Policy-Tests fuer AccessGroup, AdminGroup und Development-Admin vorhanden
- lokaler Development-Auth-Start geprueft: `http://localhost:55416` antwortet mit HTTP `200`
## Nachtrag 2026-04-29 Management Cockpit ## Nachtrag 2026-04-29 Management Cockpit
Seit dem 2026-04-17 wurden im `Management Cockpit` weitere Auswertmoeglichkeiten umgesetzt und nachtraeglich aus dem aktuellen Code rekonstruiert. Seit dem 2026-04-17 wurden im `Management Cockpit` weitere Auswertmoeglichkeiten umgesetzt und nachtraeglich aus dem aktuellen Code rekonstruiert.
+29
View File
@@ -1,6 +1,10 @@
using Microsoft.AspNetCore.Authentication;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Server.IISIntegration;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using MudBlazor.Services; using MudBlazor.Services;
using TrafagSalesExporter.Data; using TrafagSalesExporter.Data;
using TrafagSalesExporter.Security;
using TrafagSalesExporter.Services; using TrafagSalesExporter.Services;
using TrafagSalesExporter.Services.DataSources; using TrafagSalesExporter.Services.DataSources;
@@ -13,6 +17,29 @@ builder.Logging.AddFilter("Microsoft.EntityFrameworkCore.Database.Command", LogL
builder.Services.AddRazorComponents() builder.Services.AddRazorComponents()
.AddInteractiveServerComponents(); .AddInteractiveServerComponents();
builder.Services.AddCascadingAuthenticationState();
var securitySettings = builder.Configuration.GetSection(SecurityOptions.SectionName).Get<SecurityOptions>() ?? new SecurityOptions();
var useDevelopmentAuthentication = builder.Environment.IsDevelopment() && securitySettings.DevelopmentBypass;
if (useDevelopmentAuthentication)
{
builder.Services
.AddAuthentication(DevelopmentAuthenticationHandler.SchemeName)
.AddScheme<AuthenticationSchemeOptions, DevelopmentAuthenticationHandler>(
DevelopmentAuthenticationHandler.SchemeName,
options => { });
}
else
{
builder.Services.AddAuthentication(IISDefaults.AuthenticationScheme);
}
builder.Services.AddAuthorization(options =>
{
options.FallbackPolicy = SecurityPolicyFactory.BuildAccessPolicy(securitySettings, useDevelopmentAuthentication);
options.AddPolicy(SecurityPolicies.AdminOnly, SecurityPolicyFactory.BuildAdminPolicy(securitySettings, useDevelopmentAuthentication));
});
builder.Services.AddMudServices(); builder.Services.AddMudServices();
builder.Services.AddHttpClient(nameof(ExchangeRateImportService)); builder.Services.AddHttpClient(nameof(ExchangeRateImportService));
@@ -87,6 +114,8 @@ if (!app.Environment.IsDevelopment())
} }
app.UseStaticFiles(); app.UseStaticFiles();
app.UseAuthentication();
app.UseAuthorization();
app.UseAntiforgery(); app.UseAntiforgery();
app.MapRazorComponents<TrafagSalesExporter.Components.App>() app.MapRazorComponents<TrafagSalesExporter.Components.App>()
@@ -0,0 +1,43 @@
using System.Security.Claims;
using System.Text.Encodings.Web;
using Microsoft.AspNetCore.Authentication;
using Microsoft.Extensions.Options;
namespace TrafagSalesExporter.Security;
public sealed class DevelopmentAuthenticationHandler : AuthenticationHandler<AuthenticationSchemeOptions>
{
public const string SchemeName = "Development";
public const string AdminClaimType = "TrafagSalesExporter.Admin";
private readonly IConfiguration _configuration;
public DevelopmentAuthenticationHandler(
IOptionsMonitor<AuthenticationSchemeOptions> options,
ILoggerFactory logger,
UrlEncoder encoder,
IConfiguration configuration)
: base(options, logger, encoder)
{
_configuration = configuration;
}
protected override Task<AuthenticateResult> HandleAuthenticateAsync()
{
var settings = _configuration.GetSection(SecurityOptions.SectionName).Get<SecurityOptions>() ?? new SecurityOptions();
var claims = new List<Claim>
{
new(ClaimTypes.Name, settings.DevelopmentUserName),
new(ClaimTypes.NameIdentifier, settings.DevelopmentUserName)
};
if (settings.DevelopmentUserIsAdmin)
claims.Add(new Claim(AdminClaimType, "true"));
var identity = new ClaimsIdentity(claims, SchemeName);
var principal = new ClaimsPrincipal(identity);
var ticket = new AuthenticationTicket(principal, SchemeName);
return Task.FromResult(AuthenticateResult.Success(ticket));
}
}
@@ -0,0 +1,12 @@
namespace TrafagSalesExporter.Security;
public sealed class SecurityOptions
{
public const string SectionName = "Security";
public bool DevelopmentBypass { get; set; }
public bool DevelopmentUserIsAdmin { get; set; }
public string DevelopmentUserName { get; set; } = "DEV\\TrafagDeveloper";
public List<string> AccessGroups { get; set; } = [];
public List<string> AdminGroups { get; set; } = [];
}
@@ -0,0 +1,6 @@
namespace TrafagSalesExporter.Security;
public static class SecurityPolicies
{
public const string AdminOnly = nameof(AdminOnly);
}
@@ -0,0 +1,32 @@
using Microsoft.AspNetCore.Authorization;
namespace TrafagSalesExporter.Security;
public static class SecurityPolicyFactory
{
public static AuthorizationPolicy BuildAccessPolicy(SecurityOptions settings, bool useDevelopmentAuthentication)
{
var builder = new AuthorizationPolicyBuilder()
.RequireAuthenticatedUser();
if (!useDevelopmentAuthentication && settings.AccessGroups.Count > 0)
{
builder.RequireAssertion(context =>
settings.AccessGroups.Any(group => context.User.IsInRole(group)));
}
return builder.Build();
}
public static AuthorizationPolicy BuildAdminPolicy(SecurityOptions settings, bool useDevelopmentAuthentication)
{
var builder = new AuthorizationPolicyBuilder()
.RequireAuthenticatedUser();
builder.RequireAssertion(context =>
useDevelopmentAuthentication && context.User.HasClaim(DevelopmentAuthenticationHandler.AdminClaimType, "true") ||
settings.AdminGroups.Any(group => context.User.IsInRole(group)));
return builder.Build();
}
}
@@ -82,6 +82,13 @@ public class CentralSalesRecordService : ICentralSalesRecordService
PurchaseOrderNumber = r.PurchaseOrderNumber, PurchaseOrderNumber = r.PurchaseOrderNumber,
SalesPriceValue = r.SalesPriceValue, SalesPriceValue = r.SalesPriceValue,
SalesCurrency = r.SalesCurrency, SalesCurrency = r.SalesCurrency,
DocumentCurrency = r.DocumentCurrency,
DocumentTotalForeignCurrency = r.DocumentTotalForeignCurrency,
DocumentTotalLocalCurrency = r.DocumentTotalLocalCurrency,
VatSumForeignCurrency = r.VatSumForeignCurrency,
VatSumLocalCurrency = r.VatSumLocalCurrency,
DocumentRate = r.DocumentRate,
CompanyCurrency = r.CompanyCurrency,
Incoterms2020 = r.Incoterms2020, Incoterms2020 = r.Incoterms2020,
SalesResponsibleEmployee = r.SalesResponsibleEmployee, SalesResponsibleEmployee = r.SalesResponsibleEmployee,
InvoiceDate = r.InvoiceDate, InvoiceDate = r.InvoiceDate,
@@ -158,14 +165,16 @@ public class CentralSalesRecordService : ICentralSalesRecordService
Material, Name, ProductGroup, Quantity, SupplierNumber, SupplierName, SupplierCountry, Material, Name, ProductGroup, Quantity, SupplierNumber, SupplierName, SupplierCountry,
CustomerNumber, CustomerName, CustomerCountry, CustomerIndustry, StandardCost, CustomerNumber, CustomerName, CustomerCountry, CustomerIndustry, StandardCost,
StandardCostCurrency, PurchaseOrderNumber, SalesPriceValue, SalesCurrency, Incoterms2020, StandardCostCurrency, PurchaseOrderNumber, SalesPriceValue, SalesCurrency, Incoterms2020,
SalesResponsibleEmployee, InvoiceDate, OrderDate, Land, DocumentType DocumentCurrency, DocumentTotalForeignCurrency, DocumentTotalLocalCurrency, VatSumForeignCurrency,
VatSumLocalCurrency, DocumentRate, CompanyCurrency, SalesResponsibleEmployee, InvoiceDate, OrderDate, Land, DocumentType
) )
VALUES ( VALUES (
$storedAtUtc, $siteId, $sourceSystem, $extractionDate, $tsc, $invoiceNumber, $positionOnInvoice, $storedAtUtc, $siteId, $sourceSystem, $extractionDate, $tsc, $invoiceNumber, $positionOnInvoice,
$material, $name, $productGroup, $quantity, $supplierNumber, $supplierName, $supplierCountry, $material, $name, $productGroup, $quantity, $supplierNumber, $supplierName, $supplierCountry,
$customerNumber, $customerName, $customerCountry, $customerIndustry, $standardCost, $customerNumber, $customerName, $customerCountry, $customerIndustry, $standardCost,
$standardCostCurrency, $purchaseOrderNumber, $salesPriceValue, $salesCurrency, $incoterms2020, $standardCostCurrency, $purchaseOrderNumber, $salesPriceValue, $salesCurrency, $incoterms2020,
$salesResponsibleEmployee, $invoiceDate, $orderDate, $land, $documentType $documentCurrency, $documentTotalForeignCurrency, $documentTotalLocalCurrency, $vatSumForeignCurrency,
$vatSumLocalCurrency, $documentRate, $companyCurrency, $salesResponsibleEmployee, $invoiceDate, $orderDate, $land, $documentType
); );
"""; """;
@@ -192,6 +201,13 @@ public class CentralSalesRecordService : ICentralSalesRecordService
command.Parameters.Add("$purchaseOrderNumber", SqliteType.Text); command.Parameters.Add("$purchaseOrderNumber", SqliteType.Text);
command.Parameters.Add("$salesPriceValue", SqliteType.Real); command.Parameters.Add("$salesPriceValue", SqliteType.Real);
command.Parameters.Add("$salesCurrency", SqliteType.Text); command.Parameters.Add("$salesCurrency", SqliteType.Text);
command.Parameters.Add("$documentCurrency", SqliteType.Text);
command.Parameters.Add("$documentTotalForeignCurrency", SqliteType.Real);
command.Parameters.Add("$documentTotalLocalCurrency", SqliteType.Real);
command.Parameters.Add("$vatSumForeignCurrency", SqliteType.Real);
command.Parameters.Add("$vatSumLocalCurrency", SqliteType.Real);
command.Parameters.Add("$documentRate", SqliteType.Real);
command.Parameters.Add("$companyCurrency", SqliteType.Text);
command.Parameters.Add("$incoterms2020", SqliteType.Text); command.Parameters.Add("$incoterms2020", SqliteType.Text);
command.Parameters.Add("$salesResponsibleEmployee", SqliteType.Text); command.Parameters.Add("$salesResponsibleEmployee", SqliteType.Text);
command.Parameters.Add("$invoiceDate", SqliteType.Text); command.Parameters.Add("$invoiceDate", SqliteType.Text);
@@ -227,6 +243,13 @@ public class CentralSalesRecordService : ICentralSalesRecordService
command.Parameters["$purchaseOrderNumber"].Value = record.PurchaseOrderNumber ?? string.Empty; command.Parameters["$purchaseOrderNumber"].Value = record.PurchaseOrderNumber ?? string.Empty;
command.Parameters["$salesPriceValue"].Value = record.SalesPriceValue; command.Parameters["$salesPriceValue"].Value = record.SalesPriceValue;
command.Parameters["$salesCurrency"].Value = record.SalesCurrency ?? string.Empty; command.Parameters["$salesCurrency"].Value = record.SalesCurrency ?? string.Empty;
command.Parameters["$documentCurrency"].Value = record.DocumentCurrency ?? string.Empty;
command.Parameters["$documentTotalForeignCurrency"].Value = record.DocumentTotalForeignCurrency;
command.Parameters["$documentTotalLocalCurrency"].Value = record.DocumentTotalLocalCurrency;
command.Parameters["$vatSumForeignCurrency"].Value = record.VatSumForeignCurrency;
command.Parameters["$vatSumLocalCurrency"].Value = record.VatSumLocalCurrency;
command.Parameters["$documentRate"].Value = record.DocumentRate;
command.Parameters["$companyCurrency"].Value = record.CompanyCurrency ?? string.Empty;
command.Parameters["$incoterms2020"].Value = record.Incoterms2020 ?? string.Empty; command.Parameters["$incoterms2020"].Value = record.Incoterms2020 ?? string.Empty;
command.Parameters["$salesResponsibleEmployee"].Value = record.SalesResponsibleEmployee ?? string.Empty; command.Parameters["$salesResponsibleEmployee"].Value = record.SalesResponsibleEmployee ?? string.Empty;
command.Parameters["$invoiceDate"].Value = record.InvoiceDate?.ToString("O") ?? (object)DBNull.Value; command.Parameters["$invoiceDate"].Value = record.InvoiceDate?.ToString("O") ?? (object)DBNull.Value;
@@ -332,6 +332,13 @@ public class ConfigTransferService : IConfigTransferService
PurchaseOrderNumber = record.PurchaseOrderNumber, PurchaseOrderNumber = record.PurchaseOrderNumber,
SalesPriceValue = record.SalesPriceValue, SalesPriceValue = record.SalesPriceValue,
SalesCurrency = record.SalesCurrency, SalesCurrency = record.SalesCurrency,
DocumentCurrency = record.DocumentCurrency,
DocumentTotalForeignCurrency = record.DocumentTotalForeignCurrency,
DocumentTotalLocalCurrency = record.DocumentTotalLocalCurrency,
VatSumForeignCurrency = record.VatSumForeignCurrency,
VatSumLocalCurrency = record.VatSumLocalCurrency,
DocumentRate = record.DocumentRate,
CompanyCurrency = record.CompanyCurrency,
Incoterms2020 = record.Incoterms2020, Incoterms2020 = record.Incoterms2020,
SalesResponsibleEmployee = record.SalesResponsibleEmployee, SalesResponsibleEmployee = record.SalesResponsibleEmployee,
InvoiceDate = record.InvoiceDate, InvoiceDate = record.InvoiceDate,
@@ -103,6 +103,13 @@ CREATE TABLE CentralSalesRecords (
PurchaseOrderNumber TEXT NOT NULL, PurchaseOrderNumber TEXT NOT NULL,
SalesPriceValue TEXT NOT NULL, SalesPriceValue TEXT NOT NULL,
SalesCurrency TEXT NOT NULL, SalesCurrency TEXT NOT NULL,
DocumentCurrency TEXT NOT NULL DEFAULT '',
DocumentTotalForeignCurrency TEXT NOT NULL DEFAULT '0',
DocumentTotalLocalCurrency TEXT NOT NULL DEFAULT '0',
VatSumForeignCurrency TEXT NOT NULL DEFAULT '0',
VatSumLocalCurrency TEXT NOT NULL DEFAULT '0',
DocumentRate TEXT NOT NULL DEFAULT '0',
CompanyCurrency TEXT NOT NULL DEFAULT '',
Incoterms2020 TEXT NOT NULL, Incoterms2020 TEXT NOT NULL,
SalesResponsibleEmployee TEXT NOT NULL, SalesResponsibleEmployee TEXT NOT NULL,
InvoiceDate TEXT NULL, InvoiceDate TEXT NULL,
@@ -40,6 +40,13 @@ public class DatabaseSchemaMaintenanceService : IDatabaseSchemaMaintenanceServic
EnsureSapJoinTable(db); EnsureSapJoinTable(db);
EnsureSapFieldMappingTable(db); EnsureSapFieldMappingTable(db);
EnsureCentralSalesRecordTable(db); EnsureCentralSalesRecordTable(db);
AddColumnIfMissing(db, "CentralSalesRecords", "DocumentCurrency", "TEXT NOT NULL DEFAULT ''");
AddColumnIfMissing(db, "CentralSalesRecords", "DocumentTotalForeignCurrency", "TEXT NOT NULL DEFAULT '0'");
AddColumnIfMissing(db, "CentralSalesRecords", "DocumentTotalLocalCurrency", "TEXT NOT NULL DEFAULT '0'");
AddColumnIfMissing(db, "CentralSalesRecords", "VatSumForeignCurrency", "TEXT NOT NULL DEFAULT '0'");
AddColumnIfMissing(db, "CentralSalesRecords", "VatSumLocalCurrency", "TEXT NOT NULL DEFAULT '0'");
AddColumnIfMissing(db, "CentralSalesRecords", "DocumentRate", "TEXT NOT NULL DEFAULT '0'");
AddColumnIfMissing(db, "CentralSalesRecords", "CompanyCurrency", "TEXT NOT NULL DEFAULT ''");
EnsureAppEventLogTable(db); EnsureAppEventLogTable(db);
} }
@@ -60,6 +60,13 @@ public class ExcelExportService : IExcelExportService
"Purchase Order number", "Purchase Order number",
"Sales Price/Value", "Sales Price/Value",
"Sales Currency", "Sales Currency",
"Document Currency",
"Document Total FC",
"Document Total LC",
"VAT Sum FC",
"VAT Sum LC",
"Document Rate",
"Company Currency",
"Incoterms 2020", "Incoterms 2020",
"Sales responsible employee", "Sales responsible employee",
"invoice date", "invoice date",
@@ -97,12 +104,19 @@ public class ExcelExportService : IExcelExportService
ws.Cell(row, 18).Value = record.PurchaseOrderNumber; ws.Cell(row, 18).Value = record.PurchaseOrderNumber;
ws.Cell(row, 19).Value = record.SalesPriceValue; ws.Cell(row, 19).Value = record.SalesPriceValue;
ws.Cell(row, 20).Value = record.SalesCurrency; ws.Cell(row, 20).Value = record.SalesCurrency;
ws.Cell(row, 21).Value = record.Incoterms2020; ws.Cell(row, 21).Value = record.DocumentCurrency;
ws.Cell(row, 22).Value = record.SalesResponsibleEmployee; ws.Cell(row, 22).Value = record.DocumentTotalForeignCurrency;
ws.Cell(row, 23).Value = record.InvoiceDate?.ToString("dd.MM.yyyy") ?? string.Empty; ws.Cell(row, 23).Value = record.DocumentTotalLocalCurrency;
ws.Cell(row, 24).Value = record.OrderDate?.ToString("dd.MM.yyyy") ?? string.Empty; ws.Cell(row, 24).Value = record.VatSumForeignCurrency;
ws.Cell(row, 25).Value = record.Land; ws.Cell(row, 25).Value = record.VatSumLocalCurrency;
ws.Cell(row, 26).Value = record.DocumentType; ws.Cell(row, 26).Value = record.DocumentRate;
ws.Cell(row, 27).Value = record.CompanyCurrency;
ws.Cell(row, 28).Value = record.Incoterms2020;
ws.Cell(row, 29).Value = record.SalesResponsibleEmployee;
ws.Cell(row, 30).Value = record.InvoiceDate?.ToString("dd.MM.yyyy") ?? string.Empty;
ws.Cell(row, 31).Value = record.OrderDate?.ToString("dd.MM.yyyy") ?? string.Empty;
ws.Cell(row, 32).Value = record.Land;
ws.Cell(row, 33).Value = record.DocumentType;
row++; row++;
} }
@@ -177,6 +177,13 @@ public class HanaQueryService : IHanaQueryService
PurchaseOrderNumber = reader["purchase_order_number"]?.ToString() ?? string.Empty, PurchaseOrderNumber = reader["purchase_order_number"]?.ToString() ?? string.Empty,
SalesPriceValue = Convert.ToDecimal(reader["sales_value"]), SalesPriceValue = Convert.ToDecimal(reader["sales_value"]),
SalesCurrency = reader["sales_currency"]?.ToString() ?? string.Empty, SalesCurrency = reader["sales_currency"]?.ToString() ?? string.Empty,
DocumentCurrency = reader["document_currency"]?.ToString() ?? string.Empty,
DocumentTotalForeignCurrency = Convert.ToDecimal(reader["document_total_fc"]),
DocumentTotalLocalCurrency = Convert.ToDecimal(reader["document_total_lc"]),
VatSumForeignCurrency = Convert.ToDecimal(reader["vat_sum_fc"]),
VatSumLocalCurrency = Convert.ToDecimal(reader["vat_sum_lc"]),
DocumentRate = Convert.ToDecimal(reader["document_rate"]),
CompanyCurrency = reader["company_currency"]?.ToString() ?? string.Empty,
Incoterms2020 = reader["incoterms_2020"]?.ToString() ?? string.Empty, Incoterms2020 = reader["incoterms_2020"]?.ToString() ?? string.Empty,
SalesResponsibleEmployee = reader["sales_responsible"]?.ToString() ?? string.Empty, SalesResponsibleEmployee = reader["sales_responsible"]?.ToString() ?? string.Empty,
OrderDate = reader.IsDBNull(reader.GetOrdinal("order_date")) ? null : reader.GetDateTime(reader.GetOrdinal("order_date")), OrderDate = reader.IsDBNull(reader.GetOrdinal("order_date")) ? null : reader.GetDateTime(reader.GetOrdinal("order_date")),
@@ -217,12 +224,19 @@ SELECT
COALESCE(cust_adr.""Country"", '') AS customer_country, COALESCE(cust_adr.""Country"", '') AS customer_country,
COALESCE(ind.""IndName"", '') AS customer_industry, COALESCE(ind.""IndName"", '') AS customer_industry,
p.""StockPrice"" AS standard_cost, p.""StockPrice"" AS standard_cost,
COALESCE(p.""Currency"", h.""DocCur"") AS standard_cost_currency, COALESCE(adm.""MainCurncy"", '') AS standard_cost_currency,
CASE WHEN p.""BaseType"" = 22 CASE WHEN p.""BaseType"" = 22
THEN CAST(p.""BaseRef"" AS NVARCHAR(20)) THEN CAST(p.""BaseRef"" AS NVARCHAR(20))
ELSE '' END AS purchase_order_number, ELSE '' END AS purchase_order_number,
p.""LineTotal"" AS sales_value, p.""LineTotal"" AS sales_value,
COALESCE(p.""Currency"", h.""DocCur"") AS sales_currency, COALESCE(p.""Currency"", h.""DocCur"") AS sales_currency,
COALESCE(h.""DocCur"", '') AS document_currency,
COALESCE(h.""DocTotalFC"", 0) AS document_total_fc,
COALESCE(h.""DocTotal"", 0) AS document_total_lc,
COALESCE(h.""VatSumFC"", 0) AS vat_sum_fc,
COALESCE(h.""VatSum"", 0) AS vat_sum_lc,
COALESCE(h.""DocRate"", 0) AS document_rate,
COALESCE(adm.""MainCurncy"", '') AS company_currency,
'' AS incoterms_2020, '' AS incoterms_2020,
COALESCE(emp.""SlpName"", '') AS sales_responsible, COALESCE(emp.""SlpName"", '') AS sales_responsible,
CASE WHEN p.""BaseType"" = 17 CASE WHEN p.""BaseType"" = 17
@@ -232,6 +246,7 @@ SELECT
'INV' AS doc_type 'INV' AS doc_type
FROM {quotedSchema}.""OINV"" h FROM {quotedSchema}.""OINV"" h
INNER JOIN {quotedSchema}.""INV1"" p ON h.""DocEntry"" = p.""DocEntry"" INNER JOIN {quotedSchema}.""INV1"" p ON h.""DocEntry"" = p.""DocEntry""
CROSS JOIN {quotedSchema}.""OADM"" adm
LEFT JOIN {quotedSchema}.""OITM"" itm ON p.""ItemCode"" = itm.""ItemCode"" LEFT JOIN {quotedSchema}.""OITM"" itm ON p.""ItemCode"" = itm.""ItemCode""
LEFT JOIN {quotedSchema}.""OITB"" grp ON itm.""ItmsGrpCod"" = grp.""ItmsGrpCod"" LEFT JOIN {quotedSchema}.""OITB"" grp ON itm.""ItmsGrpCod"" = grp.""ItmsGrpCod""
LEFT JOIN {quotedSchema}.""OCRD"" cust ON h.""CardCode"" = cust.""CardCode"" LEFT JOIN {quotedSchema}.""OCRD"" cust ON h.""CardCode"" = cust.""CardCode""
@@ -269,16 +284,24 @@ SELECT
COALESCE(cust_adr.""Country"", '') AS customer_country, COALESCE(cust_adr.""Country"", '') AS customer_country,
COALESCE(ind.""IndName"", '') AS customer_industry, COALESCE(ind.""IndName"", '') AS customer_industry,
p.""StockPrice"" AS standard_cost, p.""StockPrice"" AS standard_cost,
COALESCE(p.""Currency"", h.""DocCur"") AS standard_cost_currency, COALESCE(adm.""MainCurncy"", '') AS standard_cost_currency,
'' AS purchase_order_number, '' AS purchase_order_number,
p.""LineTotal"" * -1 AS sales_value, p.""LineTotal"" * -1 AS sales_value,
COALESCE(p.""Currency"", h.""DocCur"") AS sales_currency, COALESCE(p.""Currency"", h.""DocCur"") AS sales_currency,
COALESCE(h.""DocCur"", '') AS document_currency,
COALESCE(h.""DocTotalFC"", 0) * -1 AS document_total_fc,
COALESCE(h.""DocTotal"", 0) * -1 AS document_total_lc,
COALESCE(h.""VatSumFC"", 0) * -1 AS vat_sum_fc,
COALESCE(h.""VatSum"", 0) * -1 AS vat_sum_lc,
COALESCE(h.""DocRate"", 0) AS document_rate,
COALESCE(adm.""MainCurncy"", '') AS company_currency,
'' AS incoterms_2020, '' AS incoterms_2020,
COALESCE(emp.""SlpName"", '') AS sales_responsible, COALESCE(emp.""SlpName"", '') AS sales_responsible,
NULL AS order_date, NULL AS order_date,
'CRN' AS doc_type 'CRN' AS doc_type
FROM {quotedSchema}.""ORIN"" h FROM {quotedSchema}.""ORIN"" h
INNER JOIN {quotedSchema}.""RIN1"" p ON h.""DocEntry"" = p.""DocEntry"" INNER JOIN {quotedSchema}.""RIN1"" p ON h.""DocEntry"" = p.""DocEntry""
CROSS JOIN {quotedSchema}.""OADM"" adm
LEFT JOIN {quotedSchema}.""OITM"" itm ON p.""ItemCode"" = itm.""ItemCode"" LEFT JOIN {quotedSchema}.""OITM"" itm ON p.""ItemCode"" = itm.""ItemCode""
LEFT JOIN {quotedSchema}.""OITB"" grp ON itm.""ItmsGrpCod"" = grp.""ItmsGrpCod"" LEFT JOIN {quotedSchema}.""OITB"" grp ON itm.""ItmsGrpCod"" = grp.""ItmsGrpCod""
LEFT JOIN {quotedSchema}.""OCRD"" cust ON h.""CardCode"" = cust.""CardCode"" LEFT JOIN {quotedSchema}.""OCRD"" cust ON h.""CardCode"" = cust.""CardCode""
@@ -28,6 +28,17 @@ public class ManualExcelImportService : IManualExcelImportService
["purchaseordernumber"] = nameof(SalesRecord.PurchaseOrderNumber), ["purchaseordernumber"] = nameof(SalesRecord.PurchaseOrderNumber),
["salespricevalue"] = nameof(SalesRecord.SalesPriceValue), ["salespricevalue"] = nameof(SalesRecord.SalesPriceValue),
["salescurrency"] = nameof(SalesRecord.SalesCurrency), ["salescurrency"] = nameof(SalesRecord.SalesCurrency),
["documentcurrency"] = nameof(SalesRecord.DocumentCurrency),
["documenttotalfc"] = nameof(SalesRecord.DocumentTotalForeignCurrency),
["documenttotalforeigncurrency"] = nameof(SalesRecord.DocumentTotalForeignCurrency),
["documenttotallc"] = nameof(SalesRecord.DocumentTotalLocalCurrency),
["documenttotallocalcurrency"] = nameof(SalesRecord.DocumentTotalLocalCurrency),
["vatsumfc"] = nameof(SalesRecord.VatSumForeignCurrency),
["vatsumforeigncurrency"] = nameof(SalesRecord.VatSumForeignCurrency),
["vatsumlc"] = nameof(SalesRecord.VatSumLocalCurrency),
["vatsumlocalcurrency"] = nameof(SalesRecord.VatSumLocalCurrency),
["documentrate"] = nameof(SalesRecord.DocumentRate),
["companycurrency"] = nameof(SalesRecord.CompanyCurrency),
["incoterms2020"] = nameof(SalesRecord.Incoterms2020), ["incoterms2020"] = nameof(SalesRecord.Incoterms2020),
["salesresponsibleemployee"] = nameof(SalesRecord.SalesResponsibleEmployee), ["salesresponsibleemployee"] = nameof(SalesRecord.SalesResponsibleEmployee),
["invoicedate"] = nameof(SalesRecord.InvoiceDate), ["invoicedate"] = nameof(SalesRecord.InvoiceDate),
@@ -75,6 +86,13 @@ public class ManualExcelImportService : IManualExcelImportService
PurchaseOrderNumber = ReadString(headerIndexes, row, nameof(SalesRecord.PurchaseOrderNumber)), PurchaseOrderNumber = ReadString(headerIndexes, row, nameof(SalesRecord.PurchaseOrderNumber)),
SalesPriceValue = ReadDecimal(headerIndexes, row, nameof(SalesRecord.SalesPriceValue)), SalesPriceValue = ReadDecimal(headerIndexes, row, nameof(SalesRecord.SalesPriceValue)),
SalesCurrency = ReadString(headerIndexes, row, nameof(SalesRecord.SalesCurrency)), SalesCurrency = ReadString(headerIndexes, row, nameof(SalesRecord.SalesCurrency)),
DocumentCurrency = ReadString(headerIndexes, row, nameof(SalesRecord.DocumentCurrency)),
DocumentTotalForeignCurrency = ReadDecimal(headerIndexes, row, nameof(SalesRecord.DocumentTotalForeignCurrency)),
DocumentTotalLocalCurrency = ReadDecimal(headerIndexes, row, nameof(SalesRecord.DocumentTotalLocalCurrency)),
VatSumForeignCurrency = ReadDecimal(headerIndexes, row, nameof(SalesRecord.VatSumForeignCurrency)),
VatSumLocalCurrency = ReadDecimal(headerIndexes, row, nameof(SalesRecord.VatSumLocalCurrency)),
DocumentRate = ReadDecimal(headerIndexes, row, nameof(SalesRecord.DocumentRate)),
CompanyCurrency = ReadString(headerIndexes, row, nameof(SalesRecord.CompanyCurrency)),
Incoterms2020 = ReadString(headerIndexes, row, nameof(SalesRecord.Incoterms2020)), Incoterms2020 = ReadString(headerIndexes, row, nameof(SalesRecord.Incoterms2020)),
SalesResponsibleEmployee = ReadString(headerIndexes, row, nameof(SalesRecord.SalesResponsibleEmployee)), SalesResponsibleEmployee = ReadString(headerIndexes, row, nameof(SalesRecord.SalesResponsibleEmployee)),
InvoiceDate = ReadDate(headerIndexes, row, nameof(SalesRecord.InvoiceDate)), InvoiceDate = ReadDate(headerIndexes, row, nameof(SalesRecord.InvoiceDate)),
@@ -0,0 +1,116 @@
using Microsoft.Data.Sqlite;
using Microsoft.EntityFrameworkCore;
using TrafagSalesExporter.Data;
using TrafagSalesExporter.Models;
using TrafagSalesExporter.Services;
namespace TrafagSalesExporter.Tests;
public class CentralSalesRecordServiceTests : IDisposable
{
private readonly SqliteConnection _connection;
private readonly TestDbContextFactory _dbFactory;
public CentralSalesRecordServiceTests()
{
_connection = new SqliteConnection("DataSource=:memory:");
_connection.Open();
var options = new DbContextOptionsBuilder<AppDbContext>()
.UseSqlite(_connection)
.Options;
using var db = new AppDbContext(options);
db.Database.EnsureCreated();
_dbFactory = new TestDbContextFactory(options);
}
public void Dispose()
{
_connection.Dispose();
}
[Fact]
public async Task ReplaceForSiteAsync_Persists_B1_Document_Currency_Fields()
{
var site = new Site
{
Id = 1,
Schema = "SBODEMO",
TSC = "TRCH",
Land = "Schweiz",
SourceSystem = "BI1",
IsActive = true
};
await using (var db = await _dbFactory.CreateDbContextAsync())
{
db.Sites.Add(site);
await db.SaveChangesAsync();
}
var service = new CentralSalesRecordService(_dbFactory, new NullAppEventLogService());
await service.ReplaceForSiteAsync(site, [
new SalesRecord
{
ExtractionDate = new DateTime(2026, 4, 29),
Tsc = "TRCH",
InvoiceNumber = "1001",
PositionOnInvoice = 1,
Material = "MAT",
Name = "Article",
ProductGroup = "PG",
Quantity = 2m,
StandardCost = 10m,
StandardCostCurrency = "CHF",
SalesPriceValue = 25m,
SalesCurrency = "EUR",
DocumentCurrency = "EUR",
DocumentTotalForeignCurrency = 100m,
DocumentTotalLocalCurrency = 95m,
VatSumForeignCurrency = 8m,
VatSumLocalCurrency = 7.6m,
DocumentRate = 0.95m,
CompanyCurrency = "CHF",
Land = "Schweiz",
DocumentType = "INV"
}
]);
var rows = await service.GetAllAsync();
var row = Assert.Single(rows);
Assert.Equal("EUR", row.DocumentCurrency);
Assert.Equal(100m, row.DocumentTotalForeignCurrency);
Assert.Equal(95m, row.DocumentTotalLocalCurrency);
Assert.Equal(8m, row.VatSumForeignCurrency);
Assert.Equal(7.6m, row.VatSumLocalCurrency);
Assert.Equal(0.95m, row.DocumentRate);
Assert.Equal("CHF", row.CompanyCurrency);
}
private sealed class NullAppEventLogService : IAppEventLogService
{
public Task WriteAsync(string category, string message, string level = "Info", int? siteId = null, string? land = null, string? details = null)
=> Task.CompletedTask;
public Task WriteDebugAsync(string category, string message, int? siteId = null, string? land = null, string? details = null)
=> Task.CompletedTask;
}
private sealed class TestDbContextFactory : IDbContextFactory<AppDbContext>
{
private readonly DbContextOptions<AppDbContext> _options;
public TestDbContextFactory(DbContextOptions<AppDbContext> options)
{
_options = options;
}
public AppDbContext CreateDbContext() => new(_options);
public Task<AppDbContext> CreateDbContextAsync(CancellationToken cancellationToken = default)
=> Task.FromResult(new AppDbContext(_options));
}
}
@@ -38,12 +38,19 @@ public class ManualExcelImportServiceTests
ws.Cell(2, 18).Value = "PO-1"; ws.Cell(2, 18).Value = "PO-1";
ws.Cell(2, 19).Value = 21.40m; ws.Cell(2, 19).Value = 21.40m;
ws.Cell(2, 20).Value = "EUR"; ws.Cell(2, 20).Value = "EUR";
ws.Cell(2, 21).Value = "DAP"; ws.Cell(2, 21).Value = "EUR";
ws.Cell(2, 22).Value = "Alice"; ws.Cell(2, 22).Value = 120.50m;
ws.Cell(2, 23).Value = "14.04.2026"; ws.Cell(2, 23).Value = 110.25m;
ws.Cell(2, 24).Value = "10.04.2026"; ws.Cell(2, 24).Value = 8.10m;
ws.Cell(2, 25).Value = "Deutschland"; ws.Cell(2, 25).Value = 7.45m;
ws.Cell(2, 26).Value = "Invoice"; ws.Cell(2, 26).Value = 1.0925m;
ws.Cell(2, 27).Value = "CHF";
ws.Cell(2, 28).Value = "DAP";
ws.Cell(2, 29).Value = "Alice";
ws.Cell(2, 30).Value = "14.04.2026";
ws.Cell(2, 31).Value = "10.04.2026";
ws.Cell(2, 32).Value = "Deutschland";
ws.Cell(2, 33).Value = "Invoice";
}); });
try try
@@ -60,6 +67,13 @@ public class ManualExcelImportServiceTests
Assert.Equal(2.5m, row.Quantity); Assert.Equal(2.5m, row.Quantity);
Assert.Equal(10.25m, row.StandardCost); Assert.Equal(10.25m, row.StandardCost);
Assert.Equal(21.40m, row.SalesPriceValue); Assert.Equal(21.40m, row.SalesPriceValue);
Assert.Equal("EUR", row.DocumentCurrency);
Assert.Equal(120.50m, row.DocumentTotalForeignCurrency);
Assert.Equal(110.25m, row.DocumentTotalLocalCurrency);
Assert.Equal(8.10m, row.VatSumForeignCurrency);
Assert.Equal(7.45m, row.VatSumLocalCurrency);
Assert.Equal(1.0925m, row.DocumentRate);
Assert.Equal("CHF", row.CompanyCurrency);
Assert.Equal("Deutschland", row.Land); Assert.Equal("Deutschland", row.Land);
Assert.Equal("Invoice", row.DocumentType); Assert.Equal("Invoice", row.DocumentType);
Assert.Equal(new DateTime(2026, 4, 14), row.InvoiceDate); Assert.Equal(new DateTime(2026, 4, 14), row.InvoiceDate);
@@ -205,6 +219,13 @@ public class ManualExcelImportServiceTests
"Purchase Order number", "Purchase Order number",
"Sales Price/Value", "Sales Price/Value",
"Sales Currency", "Sales Currency",
"Document Currency",
"Document Total FC",
"Document Total LC",
"VAT Sum FC",
"VAT Sum LC",
"Document Rate",
"Company Currency",
"Incoterms 2020", "Incoterms 2020",
"Sales responsible employee", "Sales responsible employee",
"invoice date", "invoice date",
@@ -0,0 +1,122 @@
using System.Security.Claims;
using Microsoft.AspNetCore.Authorization;
using Microsoft.Extensions.DependencyInjection;
using TrafagSalesExporter.Security;
namespace TrafagSalesExporter.Tests;
public class SecurityPolicyFactoryTests
{
[Fact]
public async Task AccessPolicy_Allows_User_In_Configured_Access_Group()
{
var policy = SecurityPolicyFactory.BuildAccessPolicy(new SecurityOptions
{
AccessGroups = ["TRAFAG\\TrafagSalesExporter-Users"]
}, useDevelopmentAuthentication: false);
var result = await AuthorizeAsync(policy, CreateUser(roles: ["TRAFAG\\TrafagSalesExporter-Users"]));
Assert.True(result.Succeeded);
}
[Fact]
public async Task AccessPolicy_Denies_User_Outside_Configured_Access_Group()
{
var policy = SecurityPolicyFactory.BuildAccessPolicy(new SecurityOptions
{
AccessGroups = ["TRAFAG\\TrafagSalesExporter-Users"]
}, useDevelopmentAuthentication: false);
var result = await AuthorizeAsync(policy, CreateUser(roles: ["TRAFAG\\OtherGroup"]));
Assert.False(result.Succeeded);
}
[Fact]
public async Task AccessPolicy_Allows_Authenticated_User_When_Development_Authentication_Is_Active()
{
var policy = SecurityPolicyFactory.BuildAccessPolicy(new SecurityOptions
{
AccessGroups = ["TRAFAG\\TrafagSalesExporter-Users"]
}, useDevelopmentAuthentication: true);
var result = await AuthorizeAsync(policy, CreateUser());
Assert.True(result.Succeeded);
}
[Fact]
public async Task AdminPolicy_Allows_User_In_Admin_Group()
{
var policy = SecurityPolicyFactory.BuildAdminPolicy(new SecurityOptions
{
AdminGroups = ["TRAFAG\\TrafagSalesExporter-Admins"]
}, useDevelopmentAuthentication: false);
var result = await AuthorizeAsync(policy, CreateUser(roles: ["TRAFAG\\TrafagSalesExporter-Admins"]));
Assert.True(result.Succeeded);
}
[Fact]
public async Task AdminPolicy_Denies_Normal_Access_User()
{
var policy = SecurityPolicyFactory.BuildAdminPolicy(new SecurityOptions
{
AdminGroups = ["TRAFAG\\TrafagSalesExporter-Admins"]
}, useDevelopmentAuthentication: false);
var result = await AuthorizeAsync(policy, CreateUser(roles: ["TRAFAG\\TrafagSalesExporter-Users"]));
Assert.False(result.Succeeded);
}
[Fact]
public async Task AdminPolicy_Allows_Development_Admin_Claim()
{
var policy = SecurityPolicyFactory.BuildAdminPolicy(new SecurityOptions(), useDevelopmentAuthentication: true);
var result = await AuthorizeAsync(policy, CreateUser(claims:
[
new Claim(DevelopmentAuthenticationHandler.AdminClaimType, "true")
]));
Assert.True(result.Succeeded);
}
[Fact]
public async Task AdminPolicy_Denies_Development_User_Without_Admin_Claim()
{
var policy = SecurityPolicyFactory.BuildAdminPolicy(new SecurityOptions(), useDevelopmentAuthentication: true);
var result = await AuthorizeAsync(policy, CreateUser());
Assert.False(result.Succeeded);
}
private static async Task<AuthorizationResult> AuthorizeAsync(AuthorizationPolicy policy, ClaimsPrincipal user)
{
var services = new ServiceCollection();
services.AddLogging();
services.AddAuthorization();
var provider = services.BuildServiceProvider();
var service = provider.GetRequiredService<IAuthorizationService>();
return await service.AuthorizeAsync(user, resource: null, policy);
}
private static ClaimsPrincipal CreateUser(IEnumerable<string>? roles = null, IEnumerable<Claim>? claims = null)
{
var allClaims = new List<Claim>
{
new(ClaimTypes.Name, "TRAFAG\\tester")
};
allClaims.AddRange((roles ?? []).Select(role => new Claim(ClaimTypes.Role, role)));
allClaims.AddRange(claims ?? []);
return new ClaimsPrincipal(new ClaimsIdentity(allClaims, "Test"));
}
}
@@ -0,0 +1,9 @@
{
"Security": {
"DevelopmentBypass": true,
"DevelopmentUserIsAdmin": true,
"DevelopmentUserName": "DEV\\TrafagDeveloper",
"AccessGroups": [],
"AdminGroups": []
}
}
+12
View File
@@ -4,5 +4,17 @@
"Default": "Information", "Default": "Information",
"Microsoft.AspNetCore": "Warning" "Microsoft.AspNetCore": "Warning"
} }
},
"Security": {
"DevelopmentBypass": false,
"DevelopmentUserIsAdmin": false,
"DevelopmentUserName": "DEV\\TrafagDeveloper",
"AccessGroups": [
"TRAFAG\\TrafagSalesExporter-Users",
"TRAFAG\\TrafagSalesExporter-Admins"
],
"AdminGroups": [
"TRAFAG\\TrafagSalesExporter-Admins"
]
} }
} }