Initial commit: SWP.Core enterprise framework with multi-tenant architecture, configuration management, security, telemetry and comprehensive test suite
This commit is contained in:
commit
5275a75502
87 changed files with 6140 additions and 0 deletions
11
.claude/settings.local.json
Normal file
11
.claude/settings.local.json
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
{
|
||||
"permissions": {
|
||||
"allow": [
|
||||
"Bash(rm:*)",
|
||||
"Bash(\"/mnt/c/Program Files/dotnet/dotnet.exe\" build --configuration Release)",
|
||||
"Bash(\"/mnt/c/Program Files/dotnet/dotnet.exe\" test --configuration Release --logger \"console;verbosity=normal\")",
|
||||
"Bash(find:*)"
|
||||
],
|
||||
"deny": []
|
||||
}
|
||||
}
|
||||
202
.editorconfig
Normal file
202
.editorconfig
Normal file
|
|
@ -0,0 +1,202 @@
|
|||
# EditorConfig is awesome: https://EditorConfig.org
|
||||
|
||||
# top-most EditorConfig file
|
||||
root = true
|
||||
|
||||
# All files
|
||||
[*]
|
||||
charset = utf-8
|
||||
end_of_line = crlf
|
||||
insert_final_newline = true
|
||||
trim_trailing_whitespace = true
|
||||
indent_style = space
|
||||
indent_size = 4
|
||||
|
||||
# Code files
|
||||
[*.{cs,csx,vb,vbx}]
|
||||
indent_size = 4
|
||||
|
||||
# XML project files
|
||||
[*.{csproj,vbproj,vcxproj,vcxproj.filters,proj,projitems,shproj}]
|
||||
indent_size = 2
|
||||
|
||||
# XML config files
|
||||
[*.{props,targets,ruleset,config,nuspec,resx,vsixmanifest,vsct}]
|
||||
indent_size = 2
|
||||
|
||||
# JSON files
|
||||
[*.{json,json5,webmanifest}]
|
||||
indent_size = 2
|
||||
|
||||
# YAML files
|
||||
[*.{yml,yaml}]
|
||||
indent_size = 2
|
||||
|
||||
# Markdown files
|
||||
[*.{md,mdx}]
|
||||
trim_trailing_whitespace = false
|
||||
|
||||
# Web files
|
||||
[*.{htm,html,js,jsm,ts,tsx,css,sass,scss,less,svg,vue}]
|
||||
indent_size = 2
|
||||
|
||||
# Batch files
|
||||
[*.{cmd,bat}]
|
||||
end_of_line = crlf
|
||||
|
||||
# Bash files
|
||||
[*.sh]
|
||||
end_of_line = lf
|
||||
|
||||
# C# files
|
||||
[*.cs]
|
||||
|
||||
# Organize usings
|
||||
dotnet_sort_system_directives_first = true
|
||||
dotnet_separate_import_directive_groups = false
|
||||
|
||||
# this. preferences
|
||||
dotnet_style_qualification_for_field = false:suggestion
|
||||
dotnet_style_qualification_for_property = false:suggestion
|
||||
dotnet_style_qualification_for_method = false:suggestion
|
||||
dotnet_style_qualification_for_event = false:suggestion
|
||||
|
||||
# Language keywords vs BCL types preferences
|
||||
dotnet_style_predefined_type_for_locals_parameters_members = true:suggestion
|
||||
dotnet_style_predefined_type_for_member_access = true:suggestion
|
||||
|
||||
# Parentheses preferences
|
||||
dotnet_style_parentheses_in_arithmetic_binary_operators = always_for_clarity:silent
|
||||
dotnet_style_parentheses_in_relational_binary_operators = always_for_clarity:silent
|
||||
dotnet_style_parentheses_in_other_binary_operators = always_for_clarity:silent
|
||||
dotnet_style_parentheses_in_other_operators = never_if_unnecessary:silent
|
||||
|
||||
# Modifier preferences
|
||||
dotnet_style_require_accessibility_modifiers = for_non_interface_members:suggestion
|
||||
dotnet_style_readonly_field = true:suggestion
|
||||
|
||||
# Expression-level preferences
|
||||
dotnet_style_object_initializer = true:suggestion
|
||||
dotnet_style_collection_initializer = true:suggestion
|
||||
dotnet_style_explicit_tuple_names = true:suggestion
|
||||
dotnet_style_null_propagation = true:suggestion
|
||||
dotnet_style_coalesce_expression = true:suggestion
|
||||
dotnet_style_prefer_is_null_check_over_reference_equality_method = true:suggestion
|
||||
dotnet_style_prefer_inferred_tuple_names = true:suggestion
|
||||
dotnet_style_prefer_inferred_anonymous_type_member_names = true:suggestion
|
||||
dotnet_style_prefer_auto_properties = true:silent
|
||||
dotnet_style_prefer_conditional_expression_over_assignment = true:silent
|
||||
dotnet_style_prefer_conditional_expression_over_return = true:silent
|
||||
|
||||
# Naming conventions
|
||||
|
||||
# Constants
|
||||
dotnet_naming_rule.constants_should_be_pascal_case.severity = suggestion
|
||||
dotnet_naming_rule.constants_should_be_pascal_case.symbols = constants
|
||||
dotnet_naming_rule.constants_should_be_pascal_case.style = pascal_case
|
||||
dotnet_naming_symbols.constants.applicable_kinds = field, local
|
||||
dotnet_naming_symbols.constants.applicable_accessibilities = *
|
||||
dotnet_naming_symbols.constants.required_modifiers = const
|
||||
dotnet_naming_style.pascal_case.capitalization = pascal_case
|
||||
|
||||
# Private constants with underscore
|
||||
dotnet_naming_rule.private_constants_should_be_camel_case_with_underscore.severity = suggestion
|
||||
dotnet_naming_rule.private_constants_should_be_camel_case_with_underscore.symbols = private_constants
|
||||
dotnet_naming_rule.private_constants_should_be_camel_case_with_underscore.style = camel_case_with_underscore
|
||||
dotnet_naming_symbols.private_constants.applicable_kinds = field
|
||||
dotnet_naming_symbols.private_constants.applicable_accessibilities = private
|
||||
dotnet_naming_symbols.private_constants.required_modifiers = const
|
||||
dotnet_naming_style.camel_case_with_underscore.capitalization = camel_case
|
||||
dotnet_naming_style.camel_case_with_underscore.required_prefix = _
|
||||
|
||||
# Static readonly fields
|
||||
dotnet_naming_rule.static_readonly_should_be_pascal_case.severity = suggestion
|
||||
dotnet_naming_rule.static_readonly_should_be_pascal_case.symbols = static_readonly
|
||||
dotnet_naming_rule.static_readonly_should_be_pascal_case.style = pascal_case
|
||||
dotnet_naming_symbols.static_readonly.applicable_kinds = field
|
||||
dotnet_naming_symbols.static_readonly.applicable_accessibilities = *
|
||||
dotnet_naming_symbols.static_readonly.required_modifiers = static, readonly
|
||||
|
||||
# Private static readonly with underscore
|
||||
dotnet_naming_rule.private_static_readonly_should_be_camel_case_with_underscore.severity = suggestion
|
||||
dotnet_naming_rule.private_static_readonly_should_be_camel_case_with_underscore.symbols = private_static_readonly
|
||||
dotnet_naming_rule.private_static_readonly_should_be_camel_case_with_underscore.style = camel_case_with_underscore
|
||||
dotnet_naming_symbols.private_static_readonly.applicable_kinds = field
|
||||
dotnet_naming_symbols.private_static_readonly.applicable_accessibilities = private
|
||||
dotnet_naming_symbols.private_static_readonly.required_modifiers = static, readonly
|
||||
|
||||
# Private fields
|
||||
dotnet_naming_rule.private_fields_should_be_camel_case_with_underscore.severity = suggestion
|
||||
dotnet_naming_rule.private_fields_should_be_camel_case_with_underscore.symbols = private_fields
|
||||
dotnet_naming_rule.private_fields_should_be_camel_case_with_underscore.style = camel_case_with_underscore
|
||||
dotnet_naming_symbols.private_fields.applicable_kinds = field
|
||||
dotnet_naming_symbols.private_fields.applicable_accessibilities = private
|
||||
|
||||
# Interfaces
|
||||
dotnet_naming_rule.interfaces_should_be_prefixed_with_i.severity = suggestion
|
||||
dotnet_naming_rule.interfaces_should_be_prefixed_with_i.symbols = interfaces
|
||||
dotnet_naming_rule.interfaces_should_be_prefixed_with_i.style = prefixed_with_i
|
||||
dotnet_naming_symbols.interfaces.applicable_kinds = interface
|
||||
dotnet_naming_style.prefixed_with_i.capitalization = pascal_case
|
||||
dotnet_naming_style.prefixed_with_i.required_prefix = I
|
||||
|
||||
# Types
|
||||
dotnet_naming_rule.types_should_be_pascal_case.severity = suggestion
|
||||
dotnet_naming_rule.types_should_be_pascal_case.symbols = types
|
||||
dotnet_naming_rule.types_should_be_pascal_case.style = pascal_case
|
||||
dotnet_naming_symbols.types.applicable_kinds = class, struct, interface, enum
|
||||
|
||||
# Non-field members
|
||||
dotnet_naming_rule.non_field_members_should_be_pascal_case.severity = suggestion
|
||||
dotnet_naming_rule.non_field_members_should_be_pascal_case.symbols = non_field_members
|
||||
dotnet_naming_rule.non_field_members_should_be_pascal_case.style = pascal_case
|
||||
dotnet_naming_symbols.non_field_members.applicable_kinds = property, event, method
|
||||
|
||||
# Parameters
|
||||
dotnet_naming_rule.parameters_should_be_camel_case.severity = suggestion
|
||||
dotnet_naming_rule.parameters_should_be_camel_case.symbols = parameters
|
||||
dotnet_naming_rule.parameters_should_be_camel_case.style = camel_case
|
||||
dotnet_naming_symbols.parameters.applicable_kinds = parameter
|
||||
dotnet_naming_style.camel_case.capitalization = camel_case
|
||||
|
||||
# Async methods
|
||||
dotnet_naming_rule.async_methods_should_end_with_async.severity = suggestion
|
||||
dotnet_naming_rule.async_methods_should_end_with_async.symbols = async_methods
|
||||
dotnet_naming_rule.async_methods_should_end_with_async.style = ends_with_async
|
||||
dotnet_naming_symbols.async_methods.applicable_kinds = method
|
||||
dotnet_naming_symbols.async_methods.required_modifiers = async
|
||||
dotnet_naming_style.ends_with_async.capitalization = pascal_case
|
||||
dotnet_naming_style.ends_with_async.required_suffix = Async
|
||||
|
||||
# C# formatting rules
|
||||
|
||||
# New line preferences
|
||||
csharp_new_line_before_open_brace = all
|
||||
csharp_new_line_before_else = true
|
||||
csharp_new_line_before_catch = true
|
||||
csharp_new_line_before_finally = true
|
||||
csharp_new_line_before_members_in_object_initializers = true
|
||||
csharp_new_line_before_members_in_anonymous_types = true
|
||||
csharp_new_line_between_query_expression_clauses = true
|
||||
|
||||
# Indentation preferences
|
||||
csharp_indent_case_contents = true
|
||||
csharp_indent_switch_labels = true
|
||||
csharp_indent_labels = flush_left
|
||||
|
||||
# Space preferences
|
||||
csharp_space_after_cast = false
|
||||
csharp_space_after_keywords_in_control_flow_statements = true
|
||||
csharp_space_between_method_call_parameter_list_parentheses = false
|
||||
csharp_space_between_method_declaration_parameter_list_parentheses = false
|
||||
csharp_space_between_parentheses = false
|
||||
csharp_space_before_colon_in_inheritance_clause = true
|
||||
csharp_space_after_colon_in_inheritance_clause = true
|
||||
csharp_space_around_binary_operators = before_and_after
|
||||
csharp_space_between_method_declaration_empty_parameter_list_parentheses = false
|
||||
csharp_space_between_method_call_name_and_opening_parenthesis = false
|
||||
csharp_space_between_method_call_empty_parameter_list_parentheses = false
|
||||
|
||||
# Wrapping preferences
|
||||
csharp_preserve_single_line_statements = true
|
||||
csharp_preserve_single_line_blocks = true
|
||||
94
.github/workflows/ci.yml
vendored
Normal file
94
.github/workflows/ci.yml
vendored
Normal file
|
|
@ -0,0 +1,94 @@
|
|||
name: CI/CD Pipeline
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [ main, develop ]
|
||||
pull_request:
|
||||
branches: [ main, develop ]
|
||||
|
||||
jobs:
|
||||
build-and-test:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
services:
|
||||
postgres:
|
||||
image: postgres:16
|
||||
env:
|
||||
POSTGRES_USER: test
|
||||
POSTGRES_PASSWORD: test
|
||||
POSTGRES_DB: swp_test
|
||||
options: >-
|
||||
--health-cmd pg_isready
|
||||
--health-interval 10s
|
||||
--health-timeout 5s
|
||||
--health-retries 5
|
||||
ports:
|
||||
- 5432:5432
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Setup .NET
|
||||
uses: actions/setup-dotnet@v4
|
||||
with:
|
||||
dotnet-version: 9.0.x
|
||||
|
||||
- name: Restore dependencies
|
||||
run: dotnet restore
|
||||
|
||||
- name: Build
|
||||
run: dotnet build --no-restore --configuration Release
|
||||
|
||||
- name: Run unit tests
|
||||
run: dotnet test --no-build --configuration Release --filter "TestCategory!=Integration" --logger "trx;LogFileName=unit-test-results.trx" --settings Tests/.runsettings
|
||||
|
||||
- name: Run integration tests
|
||||
run: dotnet test --no-build --configuration Release --filter "TestCategory=Integration" --logger "trx;LogFileName=integration-test-results.trx"
|
||||
env:
|
||||
TEST_DB_CONNECTION: "Host=localhost;Port=5432;Database=swp_test;Username=test;Password=test"
|
||||
|
||||
- name: Upload test results
|
||||
uses: actions/upload-artifact@v4
|
||||
if: always()
|
||||
with:
|
||||
name: test-results
|
||||
path: |
|
||||
**/TestResults/*.trx
|
||||
**/TestResults/*.coverage
|
||||
|
||||
- name: Code Coverage Report
|
||||
uses: irongut/CodeCoverageSummary@v1.3.0
|
||||
with:
|
||||
filename: '**/TestResults/*.coverage'
|
||||
badge: true
|
||||
format: markdown
|
||||
hide_branch_rate: false
|
||||
hide_complexity: true
|
||||
indicators: true
|
||||
output: both
|
||||
thresholds: '60 80'
|
||||
|
||||
code-quality:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Setup .NET
|
||||
uses: actions/setup-dotnet@v4
|
||||
with:
|
||||
dotnet-version: 9.0.x
|
||||
|
||||
- name: Install dotnet tools
|
||||
run: |
|
||||
dotnet tool install --global dotnet-format
|
||||
dotnet tool install --global security-scan
|
||||
|
||||
- name: Check formatting
|
||||
run: dotnet format --verify-no-changes
|
||||
|
||||
- name: Security scan
|
||||
run: security-scan --project SWP.Core.sln
|
||||
|
||||
- name: Run code analysis
|
||||
run: dotnet build /p:RunAnalyzersDuringBuild=true /p:TreatWarningsAsErrors=true
|
||||
362
.gitignore
vendored
Normal file
362
.gitignore
vendored
Normal file
|
|
@ -0,0 +1,362 @@
|
|||
# Build results
|
||||
[Dd]ebug/
|
||||
[Dd]ebugPublic/
|
||||
[Rr]elease/
|
||||
[Rr]eleases/
|
||||
x64/
|
||||
x86/
|
||||
[Ww][Ii][Nn]32/
|
||||
[Aa][Rr][Mm]/
|
||||
[Aa][Rr][Mm]64/
|
||||
bld/
|
||||
[Bb]in/
|
||||
[Oo]bj/
|
||||
[Ll]og/
|
||||
[Ll]ogs/
|
||||
|
||||
# Visual Studio 2015/2017 cache/options directory
|
||||
.vs/
|
||||
# Uncomment if you have tasks that create the project's static files in wwwroot
|
||||
#wwwroot/
|
||||
|
||||
# Visual Studio 2017 auto generated files
|
||||
Generated\ Files/
|
||||
|
||||
# MSTest test Results
|
||||
[Tt]est[Rr]esult*/
|
||||
[Bb]uild[Ll]og.*
|
||||
|
||||
# NUnit
|
||||
*.VisualState.xml
|
||||
TestResult.xml
|
||||
nunit-*.xml
|
||||
|
||||
# Build Results of an ATL Project
|
||||
[Dd]ebugPS/
|
||||
[Rr]eleasePS/
|
||||
dlldata.c
|
||||
|
||||
# Benchmark Results
|
||||
BenchmarkDotNet.Artifacts/
|
||||
|
||||
# .NET Core
|
||||
project.lock.json
|
||||
project.fragment.lock.json
|
||||
artifacts/
|
||||
|
||||
# ASP.NET Scaffolding
|
||||
ScaffoldingReadMe.txt
|
||||
|
||||
# StyleCop
|
||||
StyleCopReport.xml
|
||||
|
||||
# Files built by Visual Studio
|
||||
*_i.c
|
||||
*_p.c
|
||||
*_h.h
|
||||
*.ilk
|
||||
*.meta
|
||||
*.obj
|
||||
*.iobj
|
||||
*.pch
|
||||
*.pdb
|
||||
*.ipdb
|
||||
*.pgc
|
||||
*.pgd
|
||||
*.rsp
|
||||
*.sbr
|
||||
*.tlb
|
||||
*.tli
|
||||
*.tlh
|
||||
*.tmp
|
||||
*.tmp_proj
|
||||
*_wpftmp.csproj
|
||||
*.log
|
||||
*.tlog
|
||||
*.vspscc
|
||||
*.vssscc
|
||||
.builds
|
||||
*.pidb
|
||||
*.svclog
|
||||
*.scc
|
||||
|
||||
# Chutzpah Test files
|
||||
_Chutzpah*
|
||||
|
||||
# Visual C++ cache files
|
||||
ipch/
|
||||
*.aps
|
||||
*.ncb
|
||||
*.opendb
|
||||
*.opensdf
|
||||
*.sdf
|
||||
*.cachefile
|
||||
*.VC.db
|
||||
*.VC.VC.opendb
|
||||
|
||||
# Visual Studio profiler
|
||||
*.psess
|
||||
*.vsp
|
||||
*.vspx
|
||||
*.sap
|
||||
|
||||
# Visual Studio Trace Files
|
||||
*.e2e
|
||||
|
||||
# TFS 2012 Local Workspace
|
||||
$tf/
|
||||
|
||||
# Guidance Automation Toolkit
|
||||
*.gpState
|
||||
|
||||
# ReSharper is a .NET coding add-in
|
||||
_ReSharper*/
|
||||
*.[Rr]e[Ss]harper
|
||||
*.DotSettings.user
|
||||
|
||||
# TeamCity is a build add-in
|
||||
_TeamCity*
|
||||
|
||||
# DotCover is a Code Coverage Tool
|
||||
*.dotCover
|
||||
|
||||
# AxoCover is a Code Coverage Tool
|
||||
.axoCover/*
|
||||
!.axoCover/settings.json
|
||||
|
||||
# Coverlet is a free, cross platform Code Coverage Tool
|
||||
coverage*.json
|
||||
coverage*.xml
|
||||
coverage*.info
|
||||
|
||||
# Visual Studio code coverage results
|
||||
*.coverage
|
||||
*.coveragexml
|
||||
|
||||
# NCrunch
|
||||
_NCrunch_*
|
||||
.*crunch*.local.xml
|
||||
nCrunchTemp_*
|
||||
|
||||
# MightyMoose
|
||||
*.mm.*
|
||||
AutoTest.Net/
|
||||
|
||||
# Web workbench (sass)
|
||||
.sass-cache/
|
||||
|
||||
# Installshield output folder
|
||||
[Ee]xpress/
|
||||
|
||||
# DocProject is a documentation generator add-in
|
||||
DocProject/buildhelp/
|
||||
DocProject/Help/*.HxT
|
||||
DocProject/Help/*.HxC
|
||||
DocProject/Help/Html2
|
||||
DocProject/Help/html
|
||||
|
||||
# Click-Once directory
|
||||
publish/
|
||||
|
||||
# Publish Web Output
|
||||
*.[Pp]ublish.xml
|
||||
*.azurePubxml
|
||||
# Note: Comment the next line if you want to checkin your web deploy settings,
|
||||
# but database connection strings (with potential passwords) will be unencrypted
|
||||
*.pubxml
|
||||
*.publishproj
|
||||
|
||||
# Microsoft Azure Web App publish settings. Comment the next line if you want to
|
||||
# checkin your Azure Web App publish settings, but sensitive information contained
|
||||
# in these files may be visible to others.
|
||||
*.azurePubxml
|
||||
|
||||
# Microsoft Azure Build Output
|
||||
csx/
|
||||
*.build.csdef
|
||||
|
||||
# Microsoft Azure Emulator
|
||||
ecf/
|
||||
rcf/
|
||||
|
||||
# Windows Store app package directories and files
|
||||
AppPackages/
|
||||
BundleArtifacts/
|
||||
Package.StoreAssociation.xml
|
||||
_pkginfo.txt
|
||||
*.appx
|
||||
*.appxbundle
|
||||
*.appxupload
|
||||
|
||||
# Visual Studio cache files
|
||||
# files ending in .cache can be ignored
|
||||
*.[Cc]ache
|
||||
# but keep track of directories ending in .cache
|
||||
!?*.[Cc]ache/
|
||||
|
||||
# Others
|
||||
ClientBin/
|
||||
~$*
|
||||
*~
|
||||
*.dbmdl
|
||||
*.dbproj.schemaview
|
||||
*.jfm
|
||||
*.pfx
|
||||
*.publishsettings
|
||||
orleans.codegen.cs
|
||||
|
||||
# Including strong name files can present a security risk
|
||||
# (https://github.com/github/gitignore/pull/2483#issue-259490424)
|
||||
#*.snk
|
||||
|
||||
# Since there are multiple workflows, uncomment the next line to ignore bower_components
|
||||
# (https://github.com/github/gitignore/pull/1529#issuecomment-104372622)
|
||||
#bower_components/
|
||||
|
||||
# RIA/Silverlight projects
|
||||
Generated_Code/
|
||||
|
||||
# Backup & report files from converting an old project file
|
||||
# to a newer Visual Studio version. Backup files are not needed,
|
||||
# because we have git ;-)
|
||||
_UpgradeReport_Files/
|
||||
Backup*/
|
||||
UpgradeLog*.XML
|
||||
UpgradeLog*.htm
|
||||
CDF_UpgradeLog*.xml
|
||||
|
||||
# SQL Server files
|
||||
*.mdf
|
||||
*.ldf
|
||||
*.ndf
|
||||
|
||||
# Business Intelligence projects
|
||||
*.rdl.data
|
||||
*.bim.layout
|
||||
*.bim_*.settings
|
||||
*.rptproj.rsuser
|
||||
*- [Bb]ackup.rdl
|
||||
*- [Bb]ackup ([0-9]).rdl
|
||||
*- [Bb]ackup ([0-9][0-9]).rdl
|
||||
|
||||
# Microsoft Fakes
|
||||
FakesAssemblies/
|
||||
|
||||
# GhostDoc plugin setting file
|
||||
*.GhostDoc.xml
|
||||
|
||||
# Node.js Tools for Visual Studio
|
||||
.ntvs_analysis.dat
|
||||
node_modules/
|
||||
|
||||
# Visual Studio 6 build log
|
||||
*.plg
|
||||
|
||||
# Visual Studio 6 workspace options file
|
||||
*.opt
|
||||
|
||||
# Visual Studio 6 auto-generated workspace file (contains which files were open etc.)
|
||||
*.vbw
|
||||
|
||||
# Visual Studio 6 auto-generated project file (contains which files were open etc.)
|
||||
*.vbp
|
||||
|
||||
# Visual Studio 6 workspace and project file (working project files containing files to include in project)
|
||||
*.dsw
|
||||
*.dsp
|
||||
|
||||
# Visual Studio 6 technical files
|
||||
*.ncb
|
||||
*.aps
|
||||
|
||||
# Visual Studio LightSwitch build output
|
||||
**/*.HTMLClient/GeneratedArtifacts
|
||||
**/*.DesktopClient/GeneratedArtifacts
|
||||
**/*.DesktopClient/ModelManifest.xml
|
||||
**/*.Server/GeneratedArtifacts
|
||||
**/*.Server/ModelManifest.xml
|
||||
_Pvt_Extensions
|
||||
|
||||
# Paket dependency manager
|
||||
.paket/paket.exe
|
||||
paket-files/
|
||||
|
||||
# FAKE - F# Make
|
||||
.fake/
|
||||
|
||||
# CodeRush personal settings
|
||||
.cr/personal
|
||||
|
||||
# Python Tools for Visual Studio (PTVS)
|
||||
__pycache__/
|
||||
*.pyc
|
||||
|
||||
# Cake - Uncomment if you are using it
|
||||
# tools/**
|
||||
# !tools/packages.config
|
||||
|
||||
# Tabs Studio
|
||||
*.tss
|
||||
|
||||
# Telerik's JustMock configuration file
|
||||
*.jmconfig
|
||||
|
||||
# BizTalk build output
|
||||
*.btp.cs
|
||||
*.btm.cs
|
||||
*.odx.cs
|
||||
*.xsd.cs
|
||||
|
||||
# OpenCover UI analysis results
|
||||
OpenCover/
|
||||
|
||||
# Azure Stream Analytics local run output
|
||||
ASALocalRun/
|
||||
|
||||
# MSBuild Binary and Structured Log
|
||||
*.binlog
|
||||
|
||||
# NVidia Nsight GPU debugger configuration file
|
||||
*.nvuser
|
||||
|
||||
# MFractors (Xamarin productivity tool) working folder
|
||||
.mfractor/
|
||||
|
||||
# Local History for Visual Studio
|
||||
.localhistory/
|
||||
|
||||
# Visual Studio History (VSHistory) files
|
||||
.vshistory/
|
||||
|
||||
# BeatPulse healthcheck temp database
|
||||
healthchecksdb
|
||||
|
||||
# Backup folder for Package Reference Convert tool in Visual Studio 2017
|
||||
MigrationBackup/
|
||||
|
||||
# Ionide (cross platform F# VS Code tools) working folder
|
||||
.ionide/
|
||||
|
||||
# Fody - auto-generated XML schema
|
||||
FodyWeavers.xsd
|
||||
|
||||
# VS Code files for those working on multiple tools
|
||||
.vscode/*
|
||||
!.vscode/settings.json
|
||||
!.vscode/tasks.json
|
||||
!.vscode/launch.json
|
||||
!.vscode/extensions.json
|
||||
*.code-workspace
|
||||
|
||||
# Local History for Visual Studio Code
|
||||
.history/
|
||||
|
||||
# Windows Installer files from build outputs
|
||||
*.cab
|
||||
*.msi
|
||||
*.msix
|
||||
*.msm
|
||||
*.msp
|
||||
|
||||
# JetBrains Rider
|
||||
*.sln.iml
|
||||
136
CLAUDE.md
Normal file
136
CLAUDE.md
Normal file
|
|
@ -0,0 +1,136 @@
|
|||
# CLAUDE.md
|
||||
|
||||
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
|
||||
|
||||
## Build and Development Commands
|
||||
|
||||
```bash
|
||||
# Build the solution
|
||||
dotnet build
|
||||
|
||||
# Build in release mode
|
||||
dotnet build -c Release
|
||||
|
||||
# Clean build artifacts
|
||||
dotnet clean
|
||||
|
||||
# Restore NuGet packages
|
||||
dotnet restore
|
||||
|
||||
# Run a specific project (from project directory)
|
||||
dotnet run
|
||||
|
||||
# Run all tests
|
||||
dotnet test
|
||||
|
||||
# Run tests with detailed output
|
||||
dotnet test --logger "console;verbosity=normal"
|
||||
|
||||
# Run only unit tests (excluding integration tests)
|
||||
dotnet test --filter "TestCategory!=Integration"
|
||||
|
||||
# Run tests with coverage
|
||||
dotnet test --settings Tests/.runsettings --collect:"XPlat Code Coverage"
|
||||
```
|
||||
|
||||
## Architecture Overview
|
||||
|
||||
This is a .NET 9.0 solution with a modular architecture using Autofac for dependency injection. The codebase follows these key patterns:
|
||||
|
||||
### Core Project Structure (SWP.Core)
|
||||
- **CommandQueries**: CQRS pattern implementation with ICommand interface
|
||||
- **Configurations**: Multi-provider configuration system supporting JSON files and database-backed smart configuration
|
||||
- **Database**: PostgreSQL data access layer using Insight.Database ORM with connection factory pattern
|
||||
- **ModuleRegistry**: Autofac modules for dependency registration (Security, Telemetry, Seq Logging, Database)
|
||||
- **Telemetry**: Application Insights integration with custom enrichers and Seq telemetry channel
|
||||
- **SeqLogging**: Structured logging implementation with Seq API integration
|
||||
|
||||
### Database Project Structure (PlanTempus.Database)
|
||||
- Database setup and configuration management
|
||||
- Identity system setup (DDL)
|
||||
- User management with DCL scripts for different user types (Application, Organization, DbAdmin)
|
||||
- Tenant initialization
|
||||
- Navigation and roles/permissions systems
|
||||
|
||||
### Key Architectural Patterns
|
||||
1. **Repository Pattern**: Used for configuration management (IConfigurationRepository)
|
||||
2. **Factory Pattern**: Database connection management (IDbConnectionFactory)
|
||||
3. **Module Pattern**: Autofac modules organize dependencies by feature
|
||||
4. **CQRS Elements**: Command pattern with correlation and transaction IDs
|
||||
5. **Smart Configuration**: Database-backed configuration provider that integrates with .NET configuration system
|
||||
|
||||
### Technology Stack
|
||||
- .NET 9.0
|
||||
- PostgreSQL with Npgsql and Insight.Database ORM
|
||||
- Autofac for dependency injection
|
||||
- FluentValidation for validation
|
||||
- Seq API for structured logging
|
||||
- Application Insights for telemetry
|
||||
- Sodium.Core for encryption
|
||||
- Newtonsoft.Json for JSON processing
|
||||
|
||||
### Security Features
|
||||
- SecureTokenizer service for token generation
|
||||
- Multi-key encryption with MasterKey management
|
||||
- Secure connection string handling
|
||||
|
||||
## Testing Structure
|
||||
|
||||
The solution includes a comprehensive test project `SWP.Core.X.TDD` using:
|
||||
- **MSTest** as the test framework
|
||||
- **Moq** for mocking dependencies
|
||||
- **Shouldly** for fluent assertions
|
||||
- **Coverlet** for code coverage
|
||||
|
||||
### Test Categories
|
||||
- **Unit Tests**: Fast, isolated tests for individual components
|
||||
- **Integration Tests**: Tests requiring database or external dependencies (marked with `[TestCategory("Integration")]`)
|
||||
|
||||
### Running Specific Tests
|
||||
```bash
|
||||
# Run tests for a specific class
|
||||
dotnet test --filter "ClassName=SecureTokenizerTests"
|
||||
|
||||
# Run tests by category
|
||||
dotnet test --filter "TestCategory=Integration"
|
||||
|
||||
# Run tests by name pattern
|
||||
dotnet test --filter "Name~Token"
|
||||
```
|
||||
|
||||
### Test Helpers
|
||||
- `TestDataBuilder`: Factory methods for creating test data
|
||||
- `TestFixtureBase`: Base class for test setup and teardown
|
||||
|
||||
### Known Test Issues
|
||||
- Some tests require external dependencies (Seq logging server, PostgreSQL)
|
||||
- Integration tests are marked with `[Ignore]` when external dependencies are not available
|
||||
- Current SecureTokenizer implementation has null handling issues that need fixing
|
||||
|
||||
## Naming Conventions
|
||||
|
||||
The project follows a comprehensive naming convention documented in `NAMING_CONVENTION.md`. Key points:
|
||||
|
||||
### Code Style
|
||||
- **Namespaces**: PascalCase with `SWP.Core` prefix (not `PlanTempus.Core`)
|
||||
- **Classes**: PascalCase with appropriate suffixes (`Factory`, `Service`, `Exception`, etc.)
|
||||
- **Interfaces**: PascalCase with `I` prefix
|
||||
- **Methods**: PascalCase, async methods end with `Async`
|
||||
- **Properties**: PascalCase
|
||||
- **Private fields**: camelCase with `_` prefix
|
||||
- **Parameters**: camelCase
|
||||
|
||||
### File Organization
|
||||
- One main class per file
|
||||
- File names match class names (PascalCase)
|
||||
- Folder structure mirrors namespace hierarchy
|
||||
|
||||
### Test Naming
|
||||
- Test classes: `[ClassName]Tests`
|
||||
- Test methods: `[MethodName]_Should[Behavior]_[Condition]`
|
||||
- Test projects: `[ProjectName].X.TDD`
|
||||
|
||||
### Configuration
|
||||
- `.editorconfig` enforces formatting rules
|
||||
- Hierarchical configuration keys with PascalCase sections
|
||||
- Database uses snake_case, C# uses PascalCase with proper mapping
|
||||
194
CODE_REVIEW_FINDINGS.md
Normal file
194
CODE_REVIEW_FINDINGS.md
Normal file
|
|
@ -0,0 +1,194 @@
|
|||
# Omfattende Kodereview - SWP.Core
|
||||
|
||||
## Oversigt
|
||||
Dette dokument indeholder resultater fra en omfattende kodereview af SWP.Core løsningen. Reviewet har identificeret flere kritiske sikkerhedsproblemer, arkitektoniske udfordringer og vedligeholdelsesmæssige bekymringer.
|
||||
|
||||
## Kritiske Sikkerhedsproblemer (Skal rettes OMGÅENDE)
|
||||
|
||||
### 1. Hardkodet Master Key
|
||||
**Fil:** `Core/MultiKeyEncryption/SecureConnectionString.cs:12`
|
||||
**Problem:** Hardkodet krypteringsnøgle i kildekoden
|
||||
```csharp
|
||||
const string _masterKey = "5AFD74B1C26E87FE6656099E850DC67A";
|
||||
```
|
||||
**Anbefaling:** Implementer proper key management via Azure Key Vault eller lignende
|
||||
|
||||
### 2. Fast Salt for Alle Brugere
|
||||
**Fil:** `Core/MultiKeyEncryption/SecureConnectionString.cs:52`
|
||||
**Problem:** Bruger samme salt for alle passwords
|
||||
```csharp
|
||||
new byte[16], // Fast salt for simpelhed - i produktion bør dette være unikt per bruger
|
||||
```
|
||||
**Anbefaling:** Generer unikt salt per bruger
|
||||
|
||||
### 3. Timing Attack Sårbarhed
|
||||
**Fil:** `Core/SecureTokenizer.cs:41`
|
||||
**Problem:** Bruger `SequenceEqual()` som er sårbar overfor timing attacks
|
||||
**Anbefaling:** Implementer constant-time comparison
|
||||
|
||||
### 4. Svage Krypteringsparametre
|
||||
**Problemer:**
|
||||
- Kun 10.000 PBKDF2 iterationer (skal være 100.000+)
|
||||
- Ingen authenticated encryption (AES-GCM eller HMAC)
|
||||
- Manglende input validering
|
||||
|
||||
## Arkitektoniske Problemer
|
||||
|
||||
### 1. SOLID Princip Overtrædelser
|
||||
|
||||
#### Single Responsibility Principle (SRP)
|
||||
- `SmartConfigProvider`: Håndterer fil I/O, database queries, JSON konvertering
|
||||
- `SqlOperations`: Blander database operationer med telemetri
|
||||
- `SeqBackgroundService`: Håndterer både kø-management og netværkskommunikation
|
||||
|
||||
#### Open/Closed Principle (OCP)
|
||||
- Hardkodede dependencies forhindrer udvidelse
|
||||
- Direkte instansiering af konkrete typer i modules
|
||||
|
||||
#### Liskov Substitution Principle (LSP)
|
||||
- `PostgresConnectionFactory` eksponerer PostgreSQL-specifikke detaljer gennem generisk interface
|
||||
|
||||
#### Interface Segregation Principle (ISP)
|
||||
- `IDatabaseOperations` tvinger implementering af både generiske og ikke-generiske metoder
|
||||
- `IConfigurationProvider` har for mange ansvarsområder
|
||||
|
||||
#### Dependency Inversion Principle (DIP)
|
||||
- High-level modules afhænger direkte af low-level modules
|
||||
- Manglende abstraktion for fil-system operationer
|
||||
|
||||
### 2. Ufuldstændige Implementeringer
|
||||
|
||||
#### Command/Query Pattern
|
||||
- Interfaces defineret men mangler:
|
||||
- Command handlers
|
||||
- Query handlers
|
||||
- Mediator pattern
|
||||
- Validation pipeline
|
||||
|
||||
#### MasterKey Klasse
|
||||
**Fil:** `Core/MultiKeyEncryption/MasterKey.cs`
|
||||
- Hele klassen er udkommenteret
|
||||
- Ingen funktionalitet implementeret
|
||||
|
||||
#### Telemetry Enricher
|
||||
**Fil:** `Core/Telemetry/Enrichers/EnrichWithMetaTelemetry.cs`
|
||||
- Tom implementation med kommentar "nothing going on here yet"
|
||||
|
||||
### 3. Anti-Patterns
|
||||
|
||||
#### God Classes
|
||||
- Configuration providers håndterer for mange ansvarsområder
|
||||
- Database connection factories blander bekymringer
|
||||
|
||||
#### Feature Envy
|
||||
- Klasser der tilgår for meget ekstern state
|
||||
- Manglende indkapsling
|
||||
|
||||
#### Leaky Abstractions
|
||||
- Database-specifikke detaljer lækker gennem generiske interfaces
|
||||
- PostgreSQL typer eksponeret i abstractions
|
||||
|
||||
## Kodekvalitet Problemer
|
||||
|
||||
### 1. Inkonsistent Navngivning
|
||||
- Blanding af dansk og engelsk i kommentarer
|
||||
- Forskellige naming conventions på tværs af modules
|
||||
|
||||
### 2. Manglende Error Handling
|
||||
- Exceptions swallowed uden logging
|
||||
- Ingen retry policies for transiente fejl
|
||||
- Manglende validation af inputs
|
||||
|
||||
### 3. Hardkodede Værdier
|
||||
**Eksempler:**
|
||||
- `CommandResponse.cs:41`: `StatusUrl = "statusUrl"`
|
||||
- Security keys og connection strings
|
||||
|
||||
### 4. Ressource Management
|
||||
- Potentielle memory leaks i connection factories
|
||||
- Manglende proper disposal patterns nogle steder
|
||||
|
||||
### 5. Dependencies
|
||||
- Sodium.Core inkluderet men aldrig brugt
|
||||
- Potentielt outdated package versions
|
||||
|
||||
## Database Projekt Problemer
|
||||
|
||||
### 1. SQL Injection Risici
|
||||
- Skal verificeres at alle queries bruger parameterisering korrekt
|
||||
- Dynamisk SQL konstruktion skal undgås
|
||||
|
||||
### 2. Manglende Transaction Support
|
||||
- Ingen transaction management i database abstraction layer
|
||||
- Risiko for inkonsistent data
|
||||
|
||||
### 3. Connection Pooling
|
||||
- Ingen klar strategi for connection pooling
|
||||
- Potentielle performance problemer
|
||||
|
||||
## Manglende Komponenter
|
||||
|
||||
### 1. Caching Layer
|
||||
- Ingen caching abstraktion
|
||||
- Configuration genindlæses potentielt for ofte
|
||||
|
||||
### 2. Health Checks
|
||||
- Ingen health check endpoints
|
||||
- Manglende monitoring capabilities
|
||||
|
||||
### 3. Metrics Collection
|
||||
- Ingen business metrics abstraktion
|
||||
- Kun teknisk telemetri
|
||||
|
||||
### 4. Retry Policies
|
||||
- Ingen Polly eller lignende retry mekanismer
|
||||
- Transiente fejl håndteres ikke
|
||||
|
||||
## Test Problemer
|
||||
|
||||
### 1. Test Coverage
|
||||
- Mange komponenter uden tests
|
||||
- Kritiske sikkerhedskomponenter ikke testet
|
||||
|
||||
### 2. Integration Tests
|
||||
- Manglende database integration tests
|
||||
- Ingen end-to-end tests
|
||||
|
||||
### 3. Test Patterns
|
||||
- Inkonsistent brug af test patterns
|
||||
- Manglende test fixtures og builders
|
||||
|
||||
## Anbefalinger
|
||||
|
||||
### Øjeblikkelige Handlinger (Kritisk)
|
||||
1. Fjern hardkodet master key
|
||||
2. Implementer unikke salts per bruger
|
||||
3. Øg PBKDF2 iterationer til 100.000+
|
||||
4. Fix timing attack sårbarhed
|
||||
5. Implementer authenticated encryption
|
||||
|
||||
### Kortsigtede Forbedringer (1-2 uger)
|
||||
1. Refaktorer store klasser (SRP)
|
||||
2. Implementer manglende command/query handlers
|
||||
3. Tilføj comprehensive error handling
|
||||
4. Implementer retry policies med Polly
|
||||
5. Tilføj input validation overalt
|
||||
|
||||
### Langsigtede Forbedringer (1-3 måneder)
|
||||
1. Implementer fuld CQRS med mediator pattern
|
||||
2. Tilføj caching layer
|
||||
3. Implementer health checks
|
||||
4. Tilføj business metrics
|
||||
5. Opret comprehensive test suite
|
||||
|
||||
### Test Strategi
|
||||
1. Unit tests for alle kritiske komponenter
|
||||
2. Integration tests for database layer
|
||||
3. Security tests for kryptering
|
||||
4. Performance tests for connection pooling
|
||||
5. End-to-end tests for hele flows
|
||||
|
||||
## Konklusion
|
||||
Kodebasen viser lovende arkitektonisk tænkning men kræver betydelig refaktorering for at opnå produktionsklar kvalitet. De kritiske sikkerhedsproblemer skal adresseres øjeblikkeligt før koden bruges i produktion.
|
||||
|
||||
Prioriter sikkerhedsrettelser først, derefter arkitektoniske forbedringer, og til sidst generel kodekvalitet og test coverage.
|
||||
7
Core/CommandQueries/Command.cs
Normal file
7
Core/CommandQueries/Command.cs
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
namespace SWP.Core.CommandQueries;
|
||||
|
||||
public abstract class Command : ICommand
|
||||
{
|
||||
public required Guid CorrelationId { get; set; }
|
||||
public Guid TransactionId { get; set; }
|
||||
}
|
||||
42
Core/CommandQueries/CommandResponse.cs
Normal file
42
Core/CommandQueries/CommandResponse.cs
Normal file
|
|
@ -0,0 +1,42 @@
|
|||
namespace SWP.Core.CommandQueries;
|
||||
|
||||
/// <summary>
|
||||
/// Represents a response to a command request
|
||||
/// This class includes details such as a unique request ID, correlation ID, command name,
|
||||
/// transaction ID, creation timestamp, and a URL to check the status of the command.
|
||||
/// </summary>
|
||||
/// <param name="correlationId">A unique identifier used to track the request across services.</param>
|
||||
/// <param name="commandName">The name of the command being executed.</param>
|
||||
/// <param name="transactionId">An optional unique identifier for the transaction associated with the command.</param>
|
||||
public class CommandResponse(Guid correlationId, string commandName, Guid? transactionId)
|
||||
{
|
||||
/// <summary>
|
||||
/// A unique identifier for the request. This is automatically generated using Guid.CreateVersion7().
|
||||
/// </summary>
|
||||
public Guid RequestId { get; } = Guid.CreateVersion7();
|
||||
|
||||
/// <summary>
|
||||
/// A unique identifier used to track the request across services. This is provided when creating the response.
|
||||
/// </summary>
|
||||
public Guid CorrelationId { get; } = correlationId;
|
||||
|
||||
/// <summary>
|
||||
/// The name of the command being executed.
|
||||
/// </summary>
|
||||
public string CommandName { get; } = commandName;
|
||||
|
||||
/// <summary>
|
||||
/// An optional unique identifier for the transaction associated with the command.
|
||||
/// </summary>
|
||||
public Guid? TransactionId { get; } = transactionId;
|
||||
|
||||
/// <summary>
|
||||
/// The timestamp when the command response was created. This is automatically set to the current UTC time.
|
||||
/// </summary>
|
||||
public DateTime CreatedAt { get; } = DateTime.UtcNow;
|
||||
|
||||
/// <summary>
|
||||
/// A URL where the client can check the status of the command. This is typically used in asynchronous operations.
|
||||
/// </summary>
|
||||
public string StatusUrl { get; } = "statusUrl";
|
||||
}
|
||||
7
Core/CommandQueries/ICommand.cs
Normal file
7
Core/CommandQueries/ICommand.cs
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
namespace SWP.Core.CommandQueries;
|
||||
|
||||
public interface ICommand
|
||||
{
|
||||
Guid CorrelationId { get; set; }
|
||||
Guid TransactionId { get; set; }
|
||||
}
|
||||
56
Core/CommandQueries/ProblemDetails.cs
Normal file
56
Core/CommandQueries/ProblemDetails.cs
Normal file
|
|
@ -0,0 +1,56 @@
|
|||
namespace SWP.Core.CommandQueries;
|
||||
|
||||
/// <summary>
|
||||
/// Represents a standardized error response according to RFC 9457 (Problem Details for HTTP APIs).
|
||||
/// This class provides a consistent way to communicate errors in HTTP APIs, including details about the error type,
|
||||
/// status code, and additional context. It also supports extensions for custom error information.
|
||||
///
|
||||
/// RFC 9457 Documentation: https://www.rfc-editor.org/rfc/rfc9457.html
|
||||
/// </summary>
|
||||
public class ProblemDetails
|
||||
{
|
||||
/// <summary>
|
||||
/// A URI reference that identifies the problem type. This is typically a link to human-readable documentation about the error.
|
||||
/// </summary>
|
||||
public string Type { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// A short, human-readable summary of the problem. It should not change between occurrences of the same error.
|
||||
/// </summary>
|
||||
public string Title { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// The HTTP status code generated by the server for this occurrence of the problem. This allows the client to understand the general category of the error.
|
||||
/// </summary>
|
||||
public int? Status { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// A human-readable explanation specific to this occurrence of the problem. It provides additional details about the error.
|
||||
/// </summary>
|
||||
public string Detail { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// A URI reference that identifies the specific occurrence of the problem. This can be used to trace the error in logs or debugging tools.
|
||||
/// </summary>
|
||||
public string Instance { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// A dictionary for additional, custom error information. This allows extending the problem details with application-specific fields.
|
||||
/// </summary>
|
||||
[Newtonsoft.Json.JsonExtensionData]
|
||||
public Dictionary<string, object> Extensions { get; } = new();
|
||||
|
||||
/// <summary>
|
||||
/// Adds a custom extension to the problem details.
|
||||
/// </summary>
|
||||
/// <param name="key">The key for the extension.</param>
|
||||
/// <param name="value">The value of the extension.</param>
|
||||
public void AddExtension(string key, object value) => Extensions.Add(key, value);
|
||||
|
||||
|
||||
/// <summary>
|
||||
/// Removes a custom extension from the problem details.
|
||||
/// </summary>
|
||||
/// <param name="key">The key of the extension to remove.</param>
|
||||
public void RemoveExtension(string key) => Extensions.Remove(key);
|
||||
}
|
||||
75
Core/Configurations/Common/KeyValueToJson.cs
Normal file
75
Core/Configurations/Common/KeyValueToJson.cs
Normal file
|
|
@ -0,0 +1,75 @@
|
|||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Text;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
namespace SWP.Core.Configurations.Common
|
||||
{
|
||||
|
||||
using Newtonsoft.Json.Linq;
|
||||
using System.Collections.Generic;
|
||||
|
||||
public static class KeyValueToJson
|
||||
{
|
||||
public static JObject Convert(IEnumerable<KeyValuePair<string, JToken>> pairs)
|
||||
{
|
||||
var root = new JObject();
|
||||
|
||||
foreach (var pair in pairs)
|
||||
{
|
||||
var keys = pair.Key.Split(':');
|
||||
var current = root;
|
||||
|
||||
// Gennemgå hierarkiet og opret underobjekter, hvis de ikke eksisterer
|
||||
for (int i = 0; i < keys.Length - 1; i++)
|
||||
{
|
||||
var key = keys[i];
|
||||
|
||||
if (current[key] == null)
|
||||
current[key] = new JObject();
|
||||
|
||||
current = (JObject)current[key];
|
||||
}
|
||||
|
||||
// Håndter den sidste nøgle og tilføj værdien
|
||||
var lastKey = keys[keys.Length - 1];
|
||||
var value = ConvertValue(pair.Value);
|
||||
|
||||
// Hvis den sidste nøgle allerede eksisterer, tilføj til en liste
|
||||
if (current[lastKey] != null)
|
||||
// Hvis den allerede er en liste, tilføj til listen
|
||||
if (current[lastKey].Type == JTokenType.Array)
|
||||
((JArray)current[lastKey]).Add(value);
|
||||
// Hvis den ikke er en liste, konverter til en liste
|
||||
else
|
||||
{
|
||||
var existingValue = current[lastKey];
|
||||
current[lastKey] = new JArray { existingValue, value };
|
||||
}
|
||||
// Ellers tilføj som en enkelt værdi
|
||||
else
|
||||
current[lastKey] = value;
|
||||
}
|
||||
|
||||
return root;
|
||||
}
|
||||
|
||||
private static JToken ConvertValue(object value)
|
||||
{
|
||||
// Hvis værdien allerede er en JToken, returner den direkte
|
||||
if (value is JToken token)
|
||||
return token;
|
||||
|
||||
// Konverter andre typer
|
||||
return value switch
|
||||
{
|
||||
int i => new JValue(i),
|
||||
double d => new JValue(d),
|
||||
bool b => new JValue(b),
|
||||
string s => new JValue(s),
|
||||
_ => new JValue(value.ToString())
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
152
Core/Configurations/ConfigurationBuilder.cs
Normal file
152
Core/Configurations/ConfigurationBuilder.cs
Normal file
|
|
@ -0,0 +1,152 @@
|
|||
using Newtonsoft.Json.Linq;
|
||||
|
||||
namespace SWP.Core.Configurations
|
||||
{
|
||||
public interface IConfigurationBuilder
|
||||
{
|
||||
ConfigurationBuilder AddProvider(IConfigurationProvider provider);
|
||||
IConfigurationRoot Build();
|
||||
List<IConfigurationProvider> ConfigurationProviders { get; }
|
||||
}
|
||||
|
||||
public class ConfigurationBuilder : IConfigurationBuilder
|
||||
{
|
||||
public List<IConfigurationProvider> ConfigurationProviders { get; private set; } = [];
|
||||
|
||||
public ConfigurationBuilder AddProvider(IConfigurationProvider provider)
|
||||
{
|
||||
((IConfigurationBuilder)this).ConfigurationProviders.Add(provider);
|
||||
return this;
|
||||
}
|
||||
public IConfigurationRoot Build()
|
||||
{
|
||||
foreach (var provider in ConfigurationProviders)
|
||||
provider.Build();
|
||||
//TODO: we need to come up with merge strategy, right now the latest key-path dominates
|
||||
|
||||
return new ConfigurationRoot(ConfigurationProviders);
|
||||
}
|
||||
}
|
||||
|
||||
public class Configuration : IConfiguration
|
||||
{
|
||||
List<IConfigurationProvider> _providers = [];
|
||||
|
||||
/// <summary>
|
||||
/// Implements a string-based indexer for backwards compatibility with Microsoft.Extensions.Configuration.
|
||||
/// This implementation is marked as obsolete and should be replaced with type-safe alternatives.
|
||||
/// </summary>
|
||||
/// <param name="key">The configuration key to retrieve.</param>
|
||||
/// <returns>The configuration value for the specified key.</returns>
|
||||
/// <exception cref="NotSupportedException">Thrown when attempting to set a value, as this operation is not supported.</exception>
|
||||
[Obsolete("Use type-safe configuration methods instead")]
|
||||
public string this[string key]
|
||||
{
|
||||
get => GetConfiguration(_providers, key);
|
||||
set => throw new NotSupportedException();
|
||||
}
|
||||
|
||||
List<IConfigurationProvider> IConfiguration.ConfigurationProviders
|
||||
{
|
||||
get { return _providers; }
|
||||
set { _providers = value; }
|
||||
}
|
||||
|
||||
internal static string GetConfiguration(IList<IConfigurationProvider> providers, string key)
|
||||
{
|
||||
string value = null;
|
||||
foreach (var provider in providers)
|
||||
{
|
||||
var test = provider.Configuration().SelectToken(ConfigurationBinder.NormalizePath(key));
|
||||
|
||||
if (test != null)
|
||||
value = test.ToString();
|
||||
}
|
||||
|
||||
return value;
|
||||
}
|
||||
}
|
||||
|
||||
public class ConfigurationRoot : Configuration, IConfigurationRoot
|
||||
{
|
||||
public ConfigurationRoot(List<IConfigurationProvider> configurationProviders)
|
||||
{
|
||||
((IConfiguration)this).ConfigurationProviders = configurationProviders;
|
||||
}
|
||||
|
||||
}
|
||||
public static class ConfigurationBinder
|
||||
{
|
||||
public static string NormalizePath(string path)
|
||||
{
|
||||
return path?.Replace(":", ".", StringComparison.Ordinal) ?? string.Empty;
|
||||
}
|
||||
public static string GetConnectionString(this IConfigurationRoot configuration, string name)
|
||||
{
|
||||
return configuration.GetSection("ConnectionStrings").Get<string>(name);
|
||||
}
|
||||
public static IConfigurationSection GetSection(this IConfigurationRoot configuration, string path)
|
||||
{
|
||||
JToken value = null;
|
||||
foreach (var provider in configuration.ConfigurationProviders)
|
||||
{
|
||||
var test = provider.Configuration().SelectToken(NormalizePath(path));
|
||||
|
||||
if (test != null)
|
||||
value = test;
|
||||
}
|
||||
|
||||
return new ConfigurationSection { Path = path, Key = path.Split(':').Last(), Value = value };
|
||||
}
|
||||
public static T Get<T>(this IConfigurationRoot configuration, string path)
|
||||
{
|
||||
JToken value = null;
|
||||
foreach (var provider in configuration.ConfigurationProviders)
|
||||
{
|
||||
var test = provider.Configuration().SelectToken(NormalizePath(path));
|
||||
|
||||
if (test != null)
|
||||
value = test;
|
||||
}
|
||||
|
||||
return value.ToObject<T>();
|
||||
}
|
||||
public static T Get<T>(this IConfigurationSection configuration, string path)
|
||||
{
|
||||
var value = configuration.Value.SelectToken(NormalizePath(path)).ToObject<T>();
|
||||
return value;
|
||||
}
|
||||
public static T ToObject<T>(this IConfigurationSection configuration)
|
||||
{
|
||||
var value = configuration.Value.ToObject<T>();
|
||||
return value;
|
||||
}
|
||||
|
||||
[Obsolete("Use ToObject")]
|
||||
public static T Get<T>(this IConfigurationSection configuration)
|
||||
{
|
||||
return configuration.Value.ToObject<T>();
|
||||
}
|
||||
|
||||
}
|
||||
public interface IConfigurationProvider
|
||||
{
|
||||
void Build();
|
||||
JObject Configuration();
|
||||
}
|
||||
|
||||
public class ConfigurationSection : IConfigurationSection
|
||||
{
|
||||
public required string Path { get; set; }
|
||||
public required string Key { get; set; }
|
||||
|
||||
public required JToken Value { get; set; }
|
||||
|
||||
}
|
||||
public interface IConfigurationSection
|
||||
{
|
||||
string Path { get; }
|
||||
string Key { get; }
|
||||
JToken Value { get; set; }
|
||||
}
|
||||
}
|
||||
9
Core/Configurations/IAppConfiguration.cs
Normal file
9
Core/Configurations/IAppConfiguration.cs
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
namespace SWP.Core.Configurations
|
||||
{
|
||||
/// <summary>
|
||||
/// Marker interface for application configurations that should be automatically registered in the DI container.
|
||||
/// Classes implementing this interface will be loaded from configuration and registered as singletons.
|
||||
/// </summary>
|
||||
public interface IAppConfiguration { }
|
||||
|
||||
}
|
||||
10
Core/Configurations/IConfigurationRoot.cs
Normal file
10
Core/Configurations/IConfigurationRoot.cs
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
namespace SWP.Core.Configurations
|
||||
{
|
||||
public interface IConfigurationRoot : IConfiguration { }
|
||||
|
||||
public interface IConfiguration
|
||||
{
|
||||
internal List<IConfigurationProvider> ConfigurationProviders { get; set; }
|
||||
string this[string key] { get; set; }
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,60 @@
|
|||
using SWP.Core.Exceptions;
|
||||
using Newtonsoft.Json;
|
||||
using Newtonsoft.Json.Linq;
|
||||
|
||||
namespace SWP.Core.Configurations.JsonConfigProvider
|
||||
{
|
||||
public static class JsonConfigExtension
|
||||
{
|
||||
/// <summary>
|
||||
/// Adds a JSON configuration source to the configuration builder.
|
||||
/// </summary>
|
||||
/// <param name="builder">The configuration builder to add to</param>
|
||||
/// <param name="configurationFilePath">Path to the JSON configuration file. Defaults to "appconfiguration.json"</param>
|
||||
/// <param name="optional">If true, the configuration file is optional. Defaults to true</param>
|
||||
/// <param name="reloadOnChange">If true, the configuration will be reloaded when the file changes. Defaults to false</param>
|
||||
/// <returns>The configuration builder</returns>
|
||||
public static IConfigurationBuilder AddJsonFile(this IConfigurationBuilder builder, string configurationFilePath = "appconfiguration.json", bool? optional = true, bool? reloadOnChange = false)
|
||||
{
|
||||
return builder.AddProvider(new JsonConfigProvider(builder, configurationFilePath, optional ?? true, reloadOnChange ?? false));
|
||||
}
|
||||
}
|
||||
|
||||
public interface IHasConfigurationFilePath
|
||||
{
|
||||
string ConfigurationFilePath { get; }
|
||||
}
|
||||
public class JsonConfigProvider : IConfigurationProvider, IHasConfigurationFilePath
|
||||
{
|
||||
private readonly IConfigurationBuilder _builder;
|
||||
private readonly bool _reloadOnChange;
|
||||
JObject _configuration;
|
||||
public string ConfigurationFilePath { get; private set; }
|
||||
|
||||
public JsonConfigProvider() { }
|
||||
|
||||
public JsonConfigProvider(IConfigurationBuilder builder, string configurationFilePath, bool optional, bool reloadOnChange)
|
||||
{
|
||||
if (!optional && !File.Exists(configurationFilePath))
|
||||
throw new ConfigurationException($"File not found, path: {configurationFilePath}");
|
||||
if (optional && !File.Exists(configurationFilePath))
|
||||
return;
|
||||
|
||||
ConfigurationFilePath = configurationFilePath;
|
||||
_builder = builder;
|
||||
_reloadOnChange = reloadOnChange;
|
||||
}
|
||||
|
||||
public void Build()
|
||||
{
|
||||
using (StreamReader file = File.OpenText(ConfigurationFilePath))
|
||||
using (JsonTextReader reader = new JsonTextReader(file))
|
||||
_configuration = (JObject)JToken.ReadFrom(reader);
|
||||
}
|
||||
|
||||
public JObject Configuration()
|
||||
{
|
||||
return _configuration;
|
||||
}
|
||||
}
|
||||
}
|
||||
14
Core/Configurations/SmartConfigProvider/AppConfiguration.cs
Normal file
14
Core/Configurations/SmartConfigProvider/AppConfiguration.cs
Normal file
|
|
@ -0,0 +1,14 @@
|
|||
namespace SWP.Core.Configurations.SmartConfigProvider;
|
||||
public class AppConfiguration
|
||||
{
|
||||
public long Id { get; set; }
|
||||
public string Key { get; set; }
|
||||
public object Value { get; set; }
|
||||
public string Label { get; set; }
|
||||
public string ContentType { get; set; }
|
||||
public DateTime? ValidFrom { get; set; }
|
||||
public DateTime? ExpiresAt { get; set; }
|
||||
public DateTime? CreatedAt { get; set; }
|
||||
public DateTime? ModifiedAt { get; set; }
|
||||
public Guid? Etag { get; set; }
|
||||
}
|
||||
|
|
@ -0,0 +1,7 @@
|
|||
namespace SWP.Core.Configurations.SmartConfigProvider;
|
||||
public interface IConfigurationRepository
|
||||
{
|
||||
string ConnectionString { get; set; }
|
||||
IEnumerable<AppConfiguration> GetActiveConfigurations();
|
||||
}
|
||||
|
||||
|
|
@ -0,0 +1,35 @@
|
|||
using System.Data;
|
||||
using Insight.Database;
|
||||
using SWP.Core.Configurations.SmartConfigProvider;
|
||||
|
||||
namespace SWP.Core.Configurations.SmartConfigProvider.Repositories;
|
||||
public class PostgresConfigurationRepository : IConfigurationRepository
|
||||
{
|
||||
private IDbConnection _connection;
|
||||
public string ConnectionString { get; set; }
|
||||
|
||||
|
||||
public PostgresConfigurationRepository(string connectionString)
|
||||
{
|
||||
_connection = new Npgsql.NpgsqlConnection(connectionString);
|
||||
}
|
||||
public PostgresConfigurationRepository()
|
||||
{
|
||||
|
||||
}
|
||||
public IEnumerable<AppConfiguration> GetActiveConfigurations()
|
||||
{
|
||||
_connection ??= new Npgsql.NpgsqlConnection(ConnectionString);
|
||||
|
||||
const string sql = @"
|
||||
SELECT id, ""key"", value, label, content_type,
|
||||
valid_from, expires_at, created_at, modified_at, etag
|
||||
FROM app_configuration
|
||||
WHERE CURRENT_TIMESTAMP BETWEEN valid_from AND expires_at
|
||||
OR (valid_from IS NULL AND expires_at IS NULL)";
|
||||
|
||||
|
||||
return _connection.QuerySql<AppConfiguration>(sql);
|
||||
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,33 @@
|
|||
namespace SWP.Core.Configurations.SmartConfigProvider
|
||||
{
|
||||
/// <summary>
|
||||
/// Extension methods for adding smart configuration providers to IConfigurationBuilder.
|
||||
/// </summary>
|
||||
public static class SmartConfigExtension
|
||||
{
|
||||
/// <summary>
|
||||
/// Adds a smart configuration provider using a connection string from appsettings.
|
||||
/// </summary>
|
||||
/// <param name="builder">The configuration builder to add to</param>
|
||||
/// <param name="configKey">The key to find the connection string in the ConnectionStrings section. Defaults to "DefaultConnection"</param>
|
||||
/// <param name="path">Optional path to configuration file if different from default appsettings location</param>
|
||||
/// <returns>The configuration builder</returns>
|
||||
public static IConfigurationBuilder AddSmartConfig(this IConfigurationBuilder builder, string configKey = "DefaultConnection", string path = null)
|
||||
{
|
||||
return builder.AddProvider(new SmartConfigProvider(builder, configKey, path));
|
||||
}
|
||||
/// <summary>
|
||||
/// Adds a smart configuration provider with custom configuration options.
|
||||
/// </summary>
|
||||
/// <param name="builder">The configuration builder to add to</param>
|
||||
/// <param name="setupAction">Action to configure the smart configuration options</param>
|
||||
/// <returns>The configuration builder</returns>
|
||||
public static IConfigurationBuilder AddSmartConfig(this IConfigurationBuilder builder, Action<SmartConfigOptions> setupAction)
|
||||
{
|
||||
var options = new SmartConfigOptions();
|
||||
setupAction(options);
|
||||
|
||||
return builder.AddProvider(new SmartConfigProvider(builder, options));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,45 @@
|
|||
namespace SWP.Core.Configurations.SmartConfigProvider
|
||||
{
|
||||
/// <summary>
|
||||
/// Configuration options for setting up smart configuration providers.
|
||||
/// Provides fluent configuration methods for specifying the repository type and settings.
|
||||
/// </summary>
|
||||
public class SmartConfigOptions
|
||||
{
|
||||
private IConfigurationRepository _repository;
|
||||
internal string _configKey;
|
||||
|
||||
/// <summary>
|
||||
/// Configures the smart configuration to use PostgreSQL as the configuration store.
|
||||
/// </summary>
|
||||
/// <param name="configKey">The configuration key used to find the connection string</param>
|
||||
/// <returns>The configuration options instance for method chaining</returns>
|
||||
public SmartConfigOptions UsePostgres(string configKey)
|
||||
{
|
||||
_configKey = configKey;
|
||||
_repository = new Repositories.PostgresConfigurationRepository();
|
||||
return this;
|
||||
}
|
||||
/// <summary>
|
||||
/// Configures the smart configuration to use SQL Server as the configuration store.
|
||||
/// </summary>
|
||||
/// <returns>The configuration options instance for method chaining</returns>
|
||||
/// <exception cref="NotImplementedException">This feature is not yet implemented</exception>
|
||||
public SmartConfigOptions UseSqlServer()
|
||||
{
|
||||
throw new NotImplementedException();
|
||||
}
|
||||
/// <summary>
|
||||
/// Configures the smart configuration to use a custom configuration repository.
|
||||
/// </summary>
|
||||
/// <param name="repository">The configuration repository to use</param>
|
||||
/// <returns>The configuration options instance for method chaining</returns>
|
||||
public SmartConfigOptions UseRepository(IConfigurationRepository repository)
|
||||
{
|
||||
_repository = repository;
|
||||
return this;
|
||||
}
|
||||
|
||||
internal IConfigurationRepository GetRepository() => _repository;
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,85 @@
|
|||
using Newtonsoft.Json;
|
||||
using Newtonsoft.Json.Linq;
|
||||
using SWP.Core.Exceptions;
|
||||
using SWP.Core.Configurations.JsonConfigProvider;
|
||||
|
||||
namespace SWP.Core.Configurations.SmartConfigProvider
|
||||
{
|
||||
/// <summary>
|
||||
/// Configuration provider that loads configuration from a smart configuration source (e.g. database).
|
||||
/// The provider reads connection details from a JSON file and uses them to connect to a configuration repository.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// The provider supports multiple initialization methods:
|
||||
/// - Through SmartConfigOptions for flexible repository configuration
|
||||
/// - Through direct configuration key and file path
|
||||
/// Configuration is loaded from the repository during Build() and converted to a JSON structure.
|
||||
/// </remarks>
|
||||
public class SmartConfigProvider : IConfigurationProvider
|
||||
{
|
||||
string _configKey;
|
||||
string _connectionString;
|
||||
string _path;
|
||||
IConfigurationBuilder _builder;
|
||||
|
||||
JObject _configuration;
|
||||
SmartConfigOptions _smartConfigOptions;
|
||||
|
||||
public SmartConfigProvider() { }
|
||||
|
||||
public SmartConfigProvider(IConfigurationBuilder builder, SmartConfigOptions smartConfigOptions)
|
||||
{
|
||||
_builder = builder;
|
||||
_smartConfigOptions = smartConfigOptions;
|
||||
_configKey = smartConfigOptions._configKey;
|
||||
SetConnectionString();
|
||||
|
||||
}
|
||||
public SmartConfigProvider(IConfigurationBuilder builder, string configKey, string configurationFilePath)
|
||||
{
|
||||
_builder = builder;
|
||||
_configKey = configKey;
|
||||
_path = configurationFilePath;
|
||||
SetConnectionString();
|
||||
}
|
||||
|
||||
void SetConnectionString()
|
||||
{
|
||||
var carrier = _builder.ConfigurationProviders.OfType<IHasConfigurationFilePath>().SingleOrDefault();
|
||||
|
||||
if (carrier?.ConfigurationFilePath is null && _path is null)
|
||||
throw new ConfigurationException($"Expected a previous added ConfigurationProvider with IHasConfigurationFilePath or a configurationFilePath where to find the appsettingsfile");
|
||||
|
||||
_path ??= carrier.ConfigurationFilePath;
|
||||
|
||||
if (!File.Exists(_path))
|
||||
throw new ConfigurationException($"File not found, configurationFilePath: {_path}");
|
||||
|
||||
|
||||
using (StreamReader file = File.OpenText(_path))
|
||||
using (JsonTextReader reader = new JsonTextReader(file))
|
||||
{
|
||||
var jsonConfiguration = (JObject)JToken.ReadFrom(reader);
|
||||
|
||||
_connectionString = jsonConfiguration.SelectToken($"ConnectionStrings.{_configKey}")?.ToString();
|
||||
}
|
||||
}
|
||||
public void Build()
|
||||
{
|
||||
var repository = _smartConfigOptions.GetRepository();
|
||||
repository.ConnectionString = _connectionString;
|
||||
|
||||
var configs = repository.GetActiveConfigurations();
|
||||
|
||||
var pairs = configs.Select(x => new KeyValuePair<string, JToken>(x.Key, JToken.Parse(x.Value.ToString())));
|
||||
|
||||
_configuration = Common.KeyValueToJson.Convert(pairs);
|
||||
|
||||
}
|
||||
|
||||
public JObject Configuration()
|
||||
{
|
||||
return _configuration;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,166 @@
|
|||
using Insight.Database;
|
||||
using System.Data;
|
||||
using SWP.Core.Database.ConnectionFactory;
|
||||
|
||||
namespace SWP.Core.Database.ConfigurationManagementSystem;
|
||||
|
||||
public class SetupConfiguration(IDbConnectionFactory connectionFactory) : IDbConfigure<SetupConfiguration.Command>
|
||||
{
|
||||
public class Command { }
|
||||
|
||||
public void With(Command notInUse, ConnectionStringParameters parameters = null)
|
||||
{
|
||||
using var conn = parameters is null ? connectionFactory.Create() : connectionFactory.Create(parameters);
|
||||
using var transaction = conn.OpenWithTransaction();
|
||||
|
||||
try
|
||||
{
|
||||
CreateConfigurationTable(conn);
|
||||
CreateHistoryTable(conn);
|
||||
CreateConfigurationIndexes(conn);
|
||||
CreateModifiedAtTrigger(conn);
|
||||
CreateNotifyTrigger(conn);
|
||||
CreateHistoryTrigger(conn);
|
||||
|
||||
transaction.Commit();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
transaction.Rollback();
|
||||
throw new InvalidOperationException("Failed to SetupConfiguration in Database", ex);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
void CreateConfigurationTable(IDbConnection db)
|
||||
{
|
||||
const string sql = @"
|
||||
CREATE TABLE IF NOT EXISTS app_configuration (
|
||||
id bigserial NOT NULL,
|
||||
""key"" varchar(255) NOT NULL,
|
||||
value text NULL,
|
||||
""label"" varchar(255) NULL,
|
||||
content_type varchar(255) DEFAULT 'text/plain'::character varying NULL,
|
||||
valid_from timestamptz NULL,
|
||||
expires_at timestamptz NULL,
|
||||
created_at timestamptz DEFAULT CURRENT_TIMESTAMP NULL,
|
||||
modified_at timestamptz DEFAULT CURRENT_TIMESTAMP NULL,
|
||||
etag uuid DEFAULT gen_random_uuid() NULL,
|
||||
CONSTRAINT app_configuration_pkey PRIMARY KEY (id)
|
||||
);";
|
||||
db.ExecuteSql(sql);
|
||||
}
|
||||
|
||||
void CreateHistoryTable(IDbConnection db)
|
||||
{
|
||||
const string sql = @"
|
||||
CREATE TABLE IF NOT EXISTS app_configuration_history (
|
||||
history_id bigserial NOT NULL,
|
||||
action_type char(1) NOT NULL,
|
||||
action_timestamp timestamptz NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
action_by text NOT NULL DEFAULT CURRENT_USER,
|
||||
id bigint NOT NULL,
|
||||
""key"" varchar(255) NOT NULL,
|
||||
value text NULL,
|
||||
""label"" varchar(255) NULL,
|
||||
content_type varchar(255) NULL,
|
||||
valid_from timestamptz NULL,
|
||||
expires_at timestamptz NULL,
|
||||
created_at timestamptz NULL,
|
||||
modified_at timestamptz NULL,
|
||||
etag uuid NULL,
|
||||
CONSTRAINT app_configuration_history_pkey PRIMARY KEY (history_id)
|
||||
);";
|
||||
db.ExecuteSql(sql);
|
||||
}
|
||||
|
||||
void CreateConfigurationIndexes(IDbConnection db)
|
||||
{
|
||||
const string sql = @"
|
||||
CREATE INDEX IF NOT EXISTS idx_app_configuration_key ON app_configuration(""key"");
|
||||
CREATE INDEX IF NOT EXISTS idx_app_configuration_validity ON app_configuration(valid_from, expires_at);";
|
||||
db.ExecuteSql(sql);
|
||||
}
|
||||
|
||||
void CreateModifiedAtTrigger(IDbConnection db)
|
||||
{
|
||||
const string sql = @"
|
||||
CREATE OR REPLACE FUNCTION update_app_configuration_modified_at()
|
||||
RETURNS TRIGGER AS $$
|
||||
BEGIN
|
||||
NEW.modified_at = CURRENT_TIMESTAMP;
|
||||
RETURN NEW;
|
||||
END;
|
||||
$$ LANGUAGE plpgsql;
|
||||
|
||||
CREATE OR REPLACE TRIGGER trg_app_configuration_modified_at
|
||||
BEFORE UPDATE ON app_configuration
|
||||
FOR EACH ROW
|
||||
EXECUTE FUNCTION update_app_configuration_modified_at();";
|
||||
db.ExecuteSql(sql);
|
||||
}
|
||||
|
||||
void CreateNotifyTrigger(IDbConnection db)
|
||||
{
|
||||
const string sql = @"
|
||||
CREATE OR REPLACE FUNCTION notify_app_configuration_change()
|
||||
RETURNS TRIGGER AS $$
|
||||
BEGIN
|
||||
PERFORM pg_notify('config_changes', NEW.key);
|
||||
RETURN NEW;
|
||||
END;
|
||||
$$ LANGUAGE plpgsql;
|
||||
|
||||
CREATE OR REPLACE TRIGGER trg_app_configuration_notify
|
||||
AFTER INSERT OR UPDATE ON app_configuration
|
||||
FOR EACH ROW
|
||||
EXECUTE FUNCTION notify_app_configuration_change();";
|
||||
db.ExecuteSql(sql);
|
||||
}
|
||||
|
||||
void CreateHistoryTrigger(IDbConnection db)
|
||||
{
|
||||
const string sql = @"
|
||||
CREATE OR REPLACE FUNCTION log_app_configuration_changes()
|
||||
RETURNS TRIGGER AS $$
|
||||
BEGIN
|
||||
IF (TG_OP = 'INSERT') THEN
|
||||
INSERT INTO app_configuration_history (
|
||||
action_type, id, ""key"", value, label, content_type,
|
||||
valid_from, expires_at, created_at, modified_at, etag
|
||||
)
|
||||
VALUES (
|
||||
'I', NEW.id, NEW.key, NEW.value, NEW.label, NEW.content_type,
|
||||
NEW.valid_from, NEW.expires_at, NEW.created_at, NEW.modified_at, NEW.etag
|
||||
);
|
||||
ELSIF (TG_OP = 'UPDATE') THEN
|
||||
INSERT INTO app_configuration_history (
|
||||
action_type, id, ""key"", value, label, content_type,
|
||||
valid_from, expires_at, created_at, modified_at, etag
|
||||
)
|
||||
VALUES (
|
||||
'U', OLD.id, OLD.key, OLD.value, OLD.label, OLD.content_type,
|
||||
OLD.valid_from, OLD.expires_at, OLD.created_at, OLD.modified_at, OLD.etag
|
||||
);
|
||||
ELSIF (TG_OP = 'DELETE') THEN
|
||||
INSERT INTO app_configuration_history (
|
||||
action_type, id, ""key"", value, label, content_type,
|
||||
valid_from, expires_at, created_at, modified_at, etag
|
||||
)
|
||||
VALUES (
|
||||
'D', OLD.id, OLD.key, OLD.value, OLD.label, OLD.content_type,
|
||||
OLD.valid_from, OLD.expires_at, OLD.created_at, OLD.modified_at, OLD.etag
|
||||
);
|
||||
END IF;
|
||||
RETURN NULL;
|
||||
END;
|
||||
$$ LANGUAGE plpgsql;
|
||||
|
||||
CREATE OR REPLACE TRIGGER trg_app_configuration_history
|
||||
AFTER INSERT OR UPDATE OR DELETE ON app_configuration
|
||||
FOR EACH ROW EXECUTE FUNCTION log_app_configuration_changes();";
|
||||
db.ExecuteSql(sql);
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
8
Core/Database/ConnectionFactory/IDbConnectionFactory.cs
Normal file
8
Core/Database/ConnectionFactory/IDbConnectionFactory.cs
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
namespace SWP.Core.Database.ConnectionFactory
|
||||
{
|
||||
public interface IDbConnectionFactory
|
||||
{
|
||||
System.Data.IDbConnection Create();
|
||||
System.Data.IDbConnection Create(ConnectionStringParameters connectionStringTemplateParameters);
|
||||
}
|
||||
}
|
||||
65
Core/Database/ConnectionFactory/PostgresConnectionFactory.cs
Normal file
65
Core/Database/ConnectionFactory/PostgresConnectionFactory.cs
Normal file
|
|
@ -0,0 +1,65 @@
|
|||
using System.Data;
|
||||
using Npgsql;
|
||||
|
||||
namespace SWP.Core.Database.ConnectionFactory
|
||||
{
|
||||
|
||||
public record ConnectionStringParameters(string User, string Pwd);
|
||||
|
||||
public class PostgresConnectionFactory : IDbConnectionFactory, IAsyncDisposable
|
||||
{
|
||||
private readonly NpgsqlDataSource _baseDataSource;
|
||||
private readonly Action<NpgsqlDataSourceBuilder> _configureDataSource;
|
||||
private readonly Microsoft.Extensions.Logging.ILoggerFactory _loggerFactory; //this is not tested nor implemented, I just created it as an idea
|
||||
|
||||
public PostgresConnectionFactory(
|
||||
string connectionString,
|
||||
Microsoft.Extensions.Logging.ILoggerFactory loggerFactory = null,
|
||||
Action<NpgsqlDataSourceBuilder> configureDataSource = null)
|
||||
{
|
||||
_loggerFactory = loggerFactory;
|
||||
_configureDataSource = configureDataSource ?? (builder => { });
|
||||
|
||||
// Opret base data source med konfiguration
|
||||
var dataSourceBuilder = new NpgsqlDataSourceBuilder(connectionString);
|
||||
ConfigureDataSourceBuilder(dataSourceBuilder);
|
||||
_baseDataSource = dataSourceBuilder.Build();
|
||||
}
|
||||
|
||||
public IDbConnection Create()
|
||||
{
|
||||
return _baseDataSource.CreateConnection();
|
||||
}
|
||||
|
||||
public IDbConnection Create(ConnectionStringParameters param)
|
||||
{
|
||||
var connectionStringBuilder = new NpgsqlConnectionStringBuilder(
|
||||
_baseDataSource.ConnectionString)
|
||||
{
|
||||
Username = param.User,
|
||||
Password = param.Pwd
|
||||
};
|
||||
|
||||
var tempDataSourceBuilder = new NpgsqlDataSourceBuilder(
|
||||
connectionStringBuilder.ToString());
|
||||
|
||||
ConfigureDataSourceBuilder(tempDataSourceBuilder);
|
||||
|
||||
var tempDataSource = tempDataSourceBuilder.Build();
|
||||
return tempDataSource.CreateConnection();
|
||||
}
|
||||
|
||||
private void ConfigureDataSourceBuilder(NpgsqlDataSourceBuilder builder)
|
||||
{
|
||||
if (_loggerFactory != null)
|
||||
builder.UseLoggerFactory(_loggerFactory);
|
||||
|
||||
_configureDataSource?.Invoke(builder);
|
||||
}
|
||||
|
||||
public async ValueTask DisposeAsync()
|
||||
{
|
||||
await _baseDataSource.DisposeAsync();
|
||||
}
|
||||
}
|
||||
}
|
||||
38
Core/Database/DatabaseScope.cs
Normal file
38
Core/Database/DatabaseScope.cs
Normal file
|
|
@ -0,0 +1,38 @@
|
|||
using System.Data;
|
||||
using System.Diagnostics;
|
||||
using Microsoft.ApplicationInsights.DataContracts;
|
||||
using Microsoft.ApplicationInsights.Extensibility;
|
||||
|
||||
namespace SWP.Core.Database;
|
||||
|
||||
public class DatabaseScope : IDisposable
|
||||
{
|
||||
internal readonly IOperationHolder<DependencyTelemetry> _operation;
|
||||
private readonly Stopwatch _stopwatch;
|
||||
|
||||
public DatabaseScope(IDbConnection connection, IOperationHolder<DependencyTelemetry> operation)
|
||||
{
|
||||
Connection = connection;
|
||||
_operation = operation;
|
||||
_operation.Telemetry.Success = true;
|
||||
_operation.Telemetry.Timestamp = DateTimeOffset.UtcNow;
|
||||
_stopwatch = Stopwatch.StartNew();
|
||||
}
|
||||
|
||||
public IDbConnection Connection { get; }
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
_stopwatch.Stop();
|
||||
_operation.Telemetry.Duration = _stopwatch.Elapsed;
|
||||
|
||||
_operation.Dispose();
|
||||
Connection.Dispose();
|
||||
}
|
||||
|
||||
public void Error(Exception ex)
|
||||
{
|
||||
_operation.Telemetry.Success = false;
|
||||
_operation.Telemetry.Properties["Error"] = ex.Message;
|
||||
}
|
||||
}
|
||||
10
Core/Database/IDatabaseOperations.cs
Normal file
10
Core/Database/IDatabaseOperations.cs
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
using System.Data;
|
||||
|
||||
namespace SWP.Core.Database;
|
||||
|
||||
public interface IDatabaseOperations
|
||||
{
|
||||
DatabaseScope CreateScope(string operationName);
|
||||
Task<T> ExecuteAsync<T>(Func<IDbConnection, Task<T>> operation, string operationName);
|
||||
Task ExecuteAsync(Func<IDbConnection, Task> operation, string operationName);
|
||||
}
|
||||
9
Core/Database/IDbConfigure.cs
Normal file
9
Core/Database/IDbConfigure.cs
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
using SWP.Core.Database.ConnectionFactory;
|
||||
|
||||
namespace SWP.Core.Database
|
||||
{
|
||||
public interface IDbConfigure<T>
|
||||
{
|
||||
void With(T command, ConnectionStringParameters parameters = null);
|
||||
}
|
||||
}
|
||||
25
Core/Database/ModuleRegistry/DbPostgreSqlModule.cs
Normal file
25
Core/Database/ModuleRegistry/DbPostgreSqlModule.cs
Normal file
|
|
@ -0,0 +1,25 @@
|
|||
using Autofac;
|
||||
using SWP.Core.Database.ConnectionFactory;
|
||||
|
||||
namespace SWP.Core.Database.ModuleRegistry
|
||||
{
|
||||
|
||||
public class DbPostgreSqlModule : Module
|
||||
{
|
||||
public required string ConnectionString { get; set; }
|
||||
|
||||
protected override void Load(ContainerBuilder builder)
|
||||
{
|
||||
Insight.Database.Providers.PostgreSQL.PostgreSQLInsightDbProvider.RegisterProvider();
|
||||
|
||||
builder.RegisterType<PostgresConnectionFactory>()
|
||||
.As<IDbConnectionFactory>()
|
||||
.WithParameter(new TypedParameter(typeof(string), ConnectionString))
|
||||
.SingleInstance();
|
||||
|
||||
builder.RegisterType<SqlOperations>()
|
||||
.As<IDatabaseOperations>();
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
57
Core/Database/SqlOperations.cs
Normal file
57
Core/Database/SqlOperations.cs
Normal file
|
|
@ -0,0 +1,57 @@
|
|||
using System.Data;
|
||||
using Microsoft.ApplicationInsights;
|
||||
using Microsoft.ApplicationInsights.DataContracts;
|
||||
using SWP.Core.Database.ConnectionFactory;
|
||||
|
||||
namespace SWP.Core.Database;
|
||||
|
||||
public class SqlOperations : IDatabaseOperations
|
||||
{
|
||||
private readonly IDbConnectionFactory _connectionFactory;
|
||||
private readonly TelemetryClient _telemetryClient;
|
||||
|
||||
public SqlOperations(IDbConnectionFactory connectionFactory, TelemetryClient telemetryClient)
|
||||
{
|
||||
_connectionFactory = connectionFactory;
|
||||
_telemetryClient = telemetryClient;
|
||||
}
|
||||
|
||||
public DatabaseScope CreateScope(string operationName)
|
||||
{
|
||||
var connection = _connectionFactory.Create();
|
||||
var operation = _telemetryClient.StartOperation<DependencyTelemetry>(operationName);
|
||||
operation.Telemetry.Type = "SQL";
|
||||
operation.Telemetry.Target = "PostgreSQL";
|
||||
|
||||
return new DatabaseScope(connection, operation);
|
||||
}
|
||||
|
||||
public async Task<T> ExecuteAsync<T>(Func<IDbConnection, Task<T>> operation, string operationName)
|
||||
{
|
||||
using var scope = CreateScope(operationName);
|
||||
try
|
||||
{
|
||||
var result = await operation(scope.Connection);
|
||||
return result;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
scope.Error(ex);
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
public async Task ExecuteAsync(Func<IDbConnection, Task> operation, string operationName)
|
||||
{
|
||||
using var scope = CreateScope(operationName);
|
||||
try
|
||||
{
|
||||
await operation(scope.Connection);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
scope.Error(ex);
|
||||
throw;
|
||||
}
|
||||
}
|
||||
}
|
||||
35
Core/Entities/Users/User.cs
Normal file
35
Core/Entities/Users/User.cs
Normal file
|
|
@ -0,0 +1,35 @@
|
|||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Text;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
namespace SWP.Core.Entities.Users
|
||||
{
|
||||
public class User
|
||||
{
|
||||
public int Id { get; set; }
|
||||
public string Email { get; set; }
|
||||
public string PasswordHash { get; set; }
|
||||
public string SecurityStamp { get; set; }
|
||||
public bool EmailConfirmed { get; set; }
|
||||
public DateTime CreatedDate { get; set; }
|
||||
public DateTime? LastLoginDate { get; set; }
|
||||
}
|
||||
|
||||
public class Organization
|
||||
{
|
||||
public int Id { get; set; }
|
||||
public string ConnectionString { get; set; }
|
||||
public DateTime CreatedDate { get; set; }
|
||||
public int CreatedBy { get; set; }
|
||||
public bool IsActive { get; set; }
|
||||
}
|
||||
|
||||
public class UserOrganization
|
||||
{
|
||||
public int UserId { get; set; }
|
||||
public int OrganizationId { get; set; }
|
||||
public DateTime CreatedDate { get; set; }
|
||||
}
|
||||
}
|
||||
9
Core/Exceptions/ConfigurationException.cs
Normal file
9
Core/Exceptions/ConfigurationException.cs
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
namespace SWP.Core.Exceptions
|
||||
{
|
||||
internal class ConfigurationException : Exception
|
||||
{
|
||||
public ConfigurationException(string message) : base(message)
|
||||
{
|
||||
}
|
||||
}
|
||||
}
|
||||
8
Core/ISecureTokenizer.cs
Normal file
8
Core/ISecureTokenizer.cs
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
namespace SWP.Core
|
||||
{
|
||||
public interface ISecureTokenizer
|
||||
{
|
||||
string TokenizeText(string word);
|
||||
bool VerifyToken(string hash, string word);
|
||||
}
|
||||
}
|
||||
14
Core/ModuleRegistry/SecurityModule.cs
Normal file
14
Core/ModuleRegistry/SecurityModule.cs
Normal file
|
|
@ -0,0 +1,14 @@
|
|||
using Autofac;
|
||||
using SWP.Core.SeqLogging;
|
||||
|
||||
namespace SWP.Core.ModuleRegistry
|
||||
{
|
||||
public class SecurityModule : Module
|
||||
{
|
||||
protected override void Load(ContainerBuilder builder)
|
||||
{
|
||||
builder.RegisterType<SecureTokenizer>()
|
||||
.As<ISecureTokenizer>();
|
||||
}
|
||||
}
|
||||
}
|
||||
31
Core/ModuleRegistry/SeqLoggingModule.cs
Normal file
31
Core/ModuleRegistry/SeqLoggingModule.cs
Normal file
|
|
@ -0,0 +1,31 @@
|
|||
using Autofac;
|
||||
using SWP.Core.SeqLogging;
|
||||
|
||||
namespace SWP.Core.ModuleRegistry
|
||||
{
|
||||
public class SeqLoggingModule : Module
|
||||
{
|
||||
public required SeqConfiguration SeqConfiguration { get; set; }
|
||||
|
||||
protected override void Load(ContainerBuilder builder)
|
||||
{
|
||||
|
||||
//builder.RegisterType<MessageChannel>()
|
||||
// .As<IMessageChannel<Microsoft.ApplicationInsights.Channel.ITelemetry>>()
|
||||
// .SingleInstance();
|
||||
|
||||
builder.RegisterType<SeqBackgroundService>()
|
||||
//.As<Microsoft.Extensions.Hosting.IHostedService>()
|
||||
.SingleInstance();
|
||||
|
||||
builder.RegisterGeneric(typeof(SeqLogger<>));
|
||||
|
||||
builder.RegisterInstance(SeqConfiguration);
|
||||
|
||||
builder.RegisterType<SeqHttpClient>()
|
||||
.As<SeqHttpClient>()
|
||||
.SingleInstance();
|
||||
|
||||
}
|
||||
}
|
||||
}
|
||||
50
Core/ModuleRegistry/TelemetryModule.cs
Normal file
50
Core/ModuleRegistry/TelemetryModule.cs
Normal file
|
|
@ -0,0 +1,50 @@
|
|||
using Autofac;
|
||||
using Microsoft.ApplicationInsights.Channel;
|
||||
using Microsoft.ApplicationInsights.Extensibility;
|
||||
using SWP.Core.Telemetry;
|
||||
|
||||
namespace SWP.Core.ModuleRegistry
|
||||
{
|
||||
public class TelemetryModule : Module
|
||||
{
|
||||
public required TelemetryConfig TelemetryConfig { get; set; }
|
||||
|
||||
protected override void Load(ContainerBuilder builder)
|
||||
{
|
||||
var configuration = TelemetryConfiguration.CreateDefault();
|
||||
configuration.ConnectionString = TelemetryConfig.ConnectionString;
|
||||
configuration.TelemetryChannel.DeveloperMode = true;
|
||||
|
||||
var client = new Microsoft.ApplicationInsights.TelemetryClient(configuration);
|
||||
client.Context.GlobalProperties["Application"] = GetType().Namespace?.Split('.')[0];
|
||||
client.Context.GlobalProperties["MachineName"] = Environment.MachineName;
|
||||
client.Context.GlobalProperties["CLRVersion"] = Environment.Version.ToString();
|
||||
client.Context.GlobalProperties["ProcessorCount"] = Environment.ProcessorCount.ToString();
|
||||
|
||||
builder.Register(c => client).InstancePerLifetimeScope();
|
||||
|
||||
if (TelemetryConfig.UseSeqLoggingTelemetryChannel)
|
||||
{
|
||||
var messageChannel = new MessageChannel();
|
||||
|
||||
builder.RegisterInstance(messageChannel)
|
||||
.As<IMessageChannel<ITelemetry>>()
|
||||
.SingleInstance();
|
||||
|
||||
configuration.TelemetryChannel = new SeqTelemetryChannel(messageChannel, client);
|
||||
}
|
||||
|
||||
var telemetryProcessorChain =
|
||||
new Microsoft.ApplicationInsights.Extensibility.Implementation.TelemetryProcessorChainBuilder(
|
||||
configuration);
|
||||
telemetryProcessorChain.Use(next => new Telemetry.Enrichers.EnrichWithMetaTelemetry(next));
|
||||
telemetryProcessorChain.Build();
|
||||
}
|
||||
}
|
||||
|
||||
public class TelemetryConfig
|
||||
{
|
||||
public string ConnectionString { get; set; }
|
||||
public bool UseSeqLoggingTelemetryChannel { get; set; }
|
||||
}
|
||||
}
|
||||
28
Core/MultiKeyEncryption/MasterKey.cs
Normal file
28
Core/MultiKeyEncryption/MasterKey.cs
Normal file
|
|
@ -0,0 +1,28 @@
|
|||
namespace SWP.Core.MultiKeyEncryption
|
||||
{
|
||||
internal class MasterKey
|
||||
{
|
||||
public async Task RotateMasterKey(int tenantId, string oldMasterKey, string newMasterKey)
|
||||
{
|
||||
await Task.CompletedTask;
|
||||
// Hent alle bruger-keys for tenant
|
||||
//var users = await GetTenantUsers(tenantId);
|
||||
|
||||
//// Dekrypter connection string med gammel master key
|
||||
//var connString = DecryptWithKey(encryptedConnString, oldMasterKey);
|
||||
|
||||
//// Krypter med ny master key
|
||||
//var newEncryptedConnString = EncryptWithKey(connString, newMasterKey);
|
||||
|
||||
//// Re-krypter master key for alle brugere
|
||||
//foreach (var user in users)
|
||||
//{
|
||||
// var userKey = DeriveKeyFromPassword(user.Password);
|
||||
// var newEncryptedMasterKey = EncryptWithKey(newMasterKey, userKey);
|
||||
// await UpdateUserMasterKey(user.UserId, newEncryptedMasterKey);
|
||||
//}
|
||||
|
||||
//await UpdateTenantConnectionString(tenantId, newEncryptedConnString);
|
||||
}
|
||||
}
|
||||
}
|
||||
98
Core/MultiKeyEncryption/SecureConnectionString.cs
Normal file
98
Core/MultiKeyEncryption/SecureConnectionString.cs
Normal file
|
|
@ -0,0 +1,98 @@
|
|||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
namespace SWP.Core.MultiKeyEncryption
|
||||
{
|
||||
public class SecureConnectionString
|
||||
{
|
||||
const string _masterKey = "5AFD74B1C26E87FE6656099E850DC67A";
|
||||
|
||||
public class EncryptedData
|
||||
{
|
||||
public string EncryptedConnectionString { get; set; }
|
||||
public Dictionary<string, string> UserMasterKeys { get; set; } = new();
|
||||
}
|
||||
|
||||
public EncryptedData EncryptConnectionString(string connectionString)
|
||||
{
|
||||
var encryptedConnString = EncryptWithKey(connectionString, _masterKey);
|
||||
var userKeys = new Dictionary<string, string>();
|
||||
|
||||
|
||||
|
||||
return new EncryptedData
|
||||
{
|
||||
EncryptedConnectionString = encryptedConnString,
|
||||
UserMasterKeys = userKeys
|
||||
};
|
||||
}
|
||||
|
||||
public string AddNewUser(string username, string password)
|
||||
{
|
||||
var userKey = DeriveKeyFromPassword(password);
|
||||
var encryptedMasterKey = EncryptWithKey(_masterKey, userKey);
|
||||
return encryptedMasterKey;
|
||||
}
|
||||
|
||||
public string Decrypt(string encryptedConnString, string encryptedMasterKey, string password)
|
||||
{
|
||||
var userKey = DeriveKeyFromPassword(password);
|
||||
var masterKey = DecryptWithKey(encryptedMasterKey, userKey);
|
||||
return DecryptWithKey(encryptedConnString, masterKey);
|
||||
}
|
||||
|
||||
private string DeriveKeyFromPassword(string password)
|
||||
{
|
||||
using var deriveBytes = new Rfc2898DeriveBytes(
|
||||
password,
|
||||
new byte[16], // Fast salt for simpelhed - i produktion bør dette være unikt per bruger
|
||||
10000,
|
||||
HashAlgorithmName.SHA256);
|
||||
|
||||
return Convert.ToBase64String(deriveBytes.GetBytes(32));
|
||||
}
|
||||
|
||||
private string EncryptWithKey(string value, string key)
|
||||
{
|
||||
using var aes = Aes.Create();
|
||||
var keyBytes = Convert.FromBase64String(key);
|
||||
aes.Key = keyBytes;
|
||||
aes.GenerateIV();
|
||||
|
||||
using var encryptor = aes.CreateEncryptor();
|
||||
var valueBytes = Encoding.UTF8.GetBytes(value);
|
||||
var encrypted = encryptor.TransformFinalBlock(valueBytes, 0, valueBytes.Length);
|
||||
|
||||
var result = new byte[aes.IV.Length + encrypted.Length];
|
||||
Array.Copy(aes.IV, 0, result, 0, aes.IV.Length);
|
||||
Array.Copy(encrypted, 0, result, aes.IV.Length, encrypted.Length);
|
||||
|
||||
return Convert.ToBase64String(result);
|
||||
}
|
||||
|
||||
private string DecryptWithKey(string encryptedValue, string key)
|
||||
{
|
||||
var encryptedBytes = Convert.FromBase64String(encryptedValue);
|
||||
using var aes = Aes.Create();
|
||||
|
||||
var keyBytes = Convert.FromBase64String(key);
|
||||
aes.Key = keyBytes;
|
||||
|
||||
var iv = new byte[16];
|
||||
Array.Copy(encryptedBytes, 0, iv, 0, iv.Length);
|
||||
aes.IV = iv;
|
||||
|
||||
using var decryptor = aes.CreateDecryptor();
|
||||
var decrypted = decryptor.TransformFinalBlock(
|
||||
encryptedBytes,
|
||||
iv.Length,
|
||||
encryptedBytes.Length - iv.Length);
|
||||
|
||||
return Encoding.UTF8.GetString(decrypted);
|
||||
}
|
||||
}
|
||||
}
|
||||
28
Core/SWP.Core.csproj
Normal file
28
Core/SWP.Core.csproj
Normal file
|
|
@ -0,0 +1,28 @@
|
|||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net9.0</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Autofac" Version="8.1.1"/>
|
||||
<PackageReference Include="Autofac.Extensions.DependencyInjection" Version="10.0.0"/>
|
||||
<PackageReference Include="FluentValidation" Version="11.11.0"/>
|
||||
<PackageReference Include="Insight.Database" Version="8.0.1"/>
|
||||
<PackageReference Include="Insight.Database.Providers.PostgreSQL" Version="8.0.1"/>
|
||||
<PackageReference Include="Microsoft.ApplicationInsights" Version="2.22.0"/>
|
||||
<PackageReference Include="Microsoft.ApplicationInsights.WindowsServer.TelemetryChannel" Version="2.22.0"/>
|
||||
<PackageReference Include="Microsoft.AspNetCore.Mvc" Version="2.3.0"/>
|
||||
<PackageReference Include="Microsoft.Extensions.Hosting.Abstractions" Version="9.0.1"/>
|
||||
<PackageReference Include="npgsql" Version="9.0.2"/>
|
||||
<PackageReference Include="Seq.Api" Version="2024.3.0"/>
|
||||
<PackageReference Include="Sodium.Core" Version="1.3.5"/>
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<Folder Include="Configurations\AzureAppConfigurationProvider\"/>
|
||||
<Folder Include="Configurations\PostgresqlConfigurationBuilder\"/>
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
45
Core/SecureTokenizer.cs
Normal file
45
Core/SecureTokenizer.cs
Normal file
|
|
@ -0,0 +1,45 @@
|
|||
namespace SWP.Core
|
||||
{
|
||||
public class SecureTokenizer : ISecureTokenizer
|
||||
{
|
||||
private const int _saltSize = 16; // 128 bit
|
||||
private const int _keySize = 32; // 256 bit
|
||||
private const int _iterations = 100000;
|
||||
|
||||
public string TokenizeText(string word)
|
||||
{
|
||||
using (var algorithm = new System.Security.Cryptography.Rfc2898DeriveBytes(
|
||||
word,
|
||||
_saltSize,
|
||||
_iterations,
|
||||
System.Security.Cryptography.HashAlgorithmName.SHA256))
|
||||
{
|
||||
var key = Convert.ToBase64String(algorithm.GetBytes(_keySize));
|
||||
var salt = Convert.ToBase64String(algorithm.Salt);
|
||||
|
||||
return $"{_iterations}.{salt}.{key}";
|
||||
}
|
||||
}
|
||||
|
||||
public bool VerifyToken(string hash, string word)
|
||||
{
|
||||
var parts = hash.Split('.', 3);
|
||||
if (parts.Length != 3)
|
||||
return false;
|
||||
|
||||
var iterations = Convert.ToInt32(parts[0]);
|
||||
var salt = Convert.FromBase64String(parts[1]);
|
||||
var key = Convert.FromBase64String(parts[2]);
|
||||
|
||||
using (var algorithm = new System.Security.Cryptography.Rfc2898DeriveBytes(
|
||||
word,
|
||||
salt,
|
||||
iterations,
|
||||
System.Security.Cryptography.HashAlgorithmName.SHA256))
|
||||
{
|
||||
var keyToCheck = algorithm.GetBytes(_keySize);
|
||||
return keyToCheck.SequenceEqual(key);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
86
Core/SeqLogging/SeqBackgroundService.cs
Normal file
86
Core/SeqLogging/SeqBackgroundService.cs
Normal file
|
|
@ -0,0 +1,86 @@
|
|||
using Microsoft.ApplicationInsights;
|
||||
using Microsoft.ApplicationInsights.Channel;
|
||||
using Microsoft.ApplicationInsights.DataContracts;
|
||||
using Microsoft.Extensions.Hosting;
|
||||
using SWP.Core.Telemetry;
|
||||
|
||||
namespace SWP.Core.SeqLogging
|
||||
{
|
||||
public class SeqBackgroundService : BackgroundService
|
||||
{
|
||||
private readonly IMessageChannel<ITelemetry> _messageChannel;
|
||||
private readonly TelemetryClient _telemetryClient;
|
||||
private readonly SeqLogger<SeqBackgroundService> _seqLogger;
|
||||
|
||||
public SeqBackgroundService(TelemetryClient telemetryClient,
|
||||
IMessageChannel<ITelemetry> messageChannel,
|
||||
SeqLogger<SeqBackgroundService> seqlogger)
|
||||
{
|
||||
_telemetryClient = telemetryClient;
|
||||
_messageChannel = messageChannel;
|
||||
_seqLogger = seqlogger;
|
||||
|
||||
_telemetryClient.TrackTrace("SeqBackgroundService started");
|
||||
|
||||
}
|
||||
|
||||
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
|
||||
{
|
||||
try
|
||||
{
|
||||
while (!stoppingToken.IsCancellationRequested)
|
||||
await foreach (var telemetry in _messageChannel.Reader.ReadAllAsync(stoppingToken))
|
||||
try
|
||||
{
|
||||
switch (telemetry)
|
||||
{
|
||||
case ExceptionTelemetry et:
|
||||
await _seqLogger.LogAsync(et);
|
||||
break;
|
||||
|
||||
case TraceTelemetry et:
|
||||
await _seqLogger.LogAsync(et);
|
||||
break;
|
||||
|
||||
case DependencyTelemetry et:
|
||||
await _seqLogger.LogAsync(et);
|
||||
break;
|
||||
|
||||
case RequestTelemetry et:
|
||||
await _seqLogger.LogAsync(et);
|
||||
break;
|
||||
|
||||
case EventTelemetry et:
|
||||
await _seqLogger.LogAsync(et);
|
||||
break;
|
||||
|
||||
|
||||
default:
|
||||
throw new NotSupportedException(telemetry.GetType().Name);
|
||||
}
|
||||
}
|
||||
catch
|
||||
{
|
||||
throw;
|
||||
//_telemetryClient.TrackException(ex); this is disabled for now, we need to think about the channel structure first
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
if (ex is not OperationCanceledException)
|
||||
{
|
||||
_telemetryClient.TrackException(ex);
|
||||
throw;
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
public override async Task StopAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
_telemetryClient.TrackTrace("StopAsync called: Service shutdown started");
|
||||
_messageChannel.Dispose();
|
||||
await base.StopAsync(cancellationToken);
|
||||
}
|
||||
}
|
||||
}
|
||||
4
Core/SeqLogging/SeqConfiguration.cs
Normal file
4
Core/SeqLogging/SeqConfiguration.cs
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
namespace SWP.Core.SeqLogging
|
||||
{
|
||||
public record SeqConfiguration(string IngestionEndpoint, string ApiKey, string Environment);
|
||||
}
|
||||
28
Core/SeqLogging/SeqHttpClient.cs
Normal file
28
Core/SeqLogging/SeqHttpClient.cs
Normal file
|
|
@ -0,0 +1,28 @@
|
|||
namespace SWP.Core.SeqLogging
|
||||
{
|
||||
public class SeqHttpClient
|
||||
{
|
||||
HttpClient _httpClient;
|
||||
|
||||
public SeqHttpClient(SeqConfiguration seqConfiguration, HttpMessageHandler httpMessageHandler)
|
||||
{
|
||||
_httpClient = new HttpClient(httpMessageHandler)
|
||||
{
|
||||
BaseAddress = new Uri(seqConfiguration.IngestionEndpoint),
|
||||
Timeout = TimeSpan.FromSeconds(30)
|
||||
};
|
||||
|
||||
_httpClient.DefaultRequestHeaders.Accept.Clear();
|
||||
_httpClient.DefaultRequestHeaders.Accept.Add(new System.Net.Http.Headers.MediaTypeWithQualityHeaderValue("application/json"));
|
||||
if (seqConfiguration.ApiKey != null)
|
||||
_httpClient.DefaultRequestHeaders.Add("X-Seq-ApiKey", seqConfiguration.ApiKey);
|
||||
}
|
||||
|
||||
public SeqHttpClient(SeqConfiguration seqConfiguration) : this(seqConfiguration, new HttpClientHandler()) { }
|
||||
|
||||
public async Task<HttpResponseMessage> SendAsync(HttpRequestMessage httpRequestMessage, CancellationToken cancellationToken)
|
||||
{
|
||||
return await _httpClient.SendAsync(httpRequestMessage, cancellationToken);
|
||||
}
|
||||
}
|
||||
}
|
||||
236
Core/SeqLogging/SeqLogger.cs
Normal file
236
Core/SeqLogging/SeqLogger.cs
Normal file
|
|
@ -0,0 +1,236 @@
|
|||
using System.Text;
|
||||
using Microsoft.ApplicationInsights.DataContracts;
|
||||
|
||||
namespace SWP.Core.SeqLogging
|
||||
{
|
||||
public class SeqLogger<T>
|
||||
{
|
||||
private readonly SeqHttpClient _httpClient;
|
||||
private readonly SeqConfiguration _configuration;
|
||||
|
||||
public SeqLogger(SeqHttpClient httpClient, SeqConfiguration configuration)
|
||||
{
|
||||
_httpClient = httpClient;
|
||||
_configuration = configuration;
|
||||
}
|
||||
|
||||
public async Task LogAsync(TraceTelemetry trace, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var seqEvent = new Dictionary<string, object>
|
||||
{
|
||||
{ "@t", trace.Timestamp.UtcDateTime.ToString("o") },
|
||||
{ "@mt", trace.Message },
|
||||
{ "@l", MapSeverityToLevel(trace.SeverityLevel) },
|
||||
{ "Environment", _configuration.Environment },
|
||||
};
|
||||
|
||||
foreach (var prop in trace.Properties)
|
||||
seqEvent.Add($"prop_{prop.Key}", prop.Value);
|
||||
|
||||
foreach (var prop in trace.Context.GlobalProperties)
|
||||
seqEvent.Add($"global_{prop.Key}", prop.Value);
|
||||
|
||||
await SendToSeqAsync(seqEvent, cancellationToken);
|
||||
}
|
||||
|
||||
public async Task LogAsync(EventTelemetry evt, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var seqEvent = new Dictionary<string, object>
|
||||
{
|
||||
{ "@t", evt.Timestamp.UtcDateTime.ToString("o") },
|
||||
{ "@mt", evt.Name },
|
||||
{ "@l", "Information" },
|
||||
{ "Environment", _configuration.Environment }
|
||||
};
|
||||
|
||||
foreach (var prop in evt.Properties)
|
||||
seqEvent.Add($"prop_{prop.Key}", prop.Value);
|
||||
|
||||
foreach (var prop in evt.Context.GlobalProperties)
|
||||
seqEvent.Add($"global_{prop.Key}", prop.Value);
|
||||
|
||||
foreach (var metric in evt.Metrics)
|
||||
seqEvent.Add($"metric_{metric.Key}", metric.Value);
|
||||
|
||||
await SendToSeqAsync(seqEvent, cancellationToken);
|
||||
}
|
||||
|
||||
public async Task LogAsync(ExceptionTelemetry ex, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var seqEvent = new Dictionary<string, object>
|
||||
{
|
||||
{ "@t", ex.Timestamp.UtcDateTime.ToString("o") },
|
||||
{ "@mt", ex.Exception.Message },
|
||||
{ "@l", "Error" },
|
||||
{ "@x", FormatExceptionForSeq(ex.Exception) },
|
||||
{ "Environment", _configuration.Environment },
|
||||
{ "ExceptionType", ex.Exception.GetType().Name }
|
||||
};
|
||||
|
||||
foreach (var prop in ex.Properties)
|
||||
seqEvent.Add($"prop_{prop.Key}", prop.Value);
|
||||
|
||||
foreach (var prop in ex.Context.GlobalProperties)
|
||||
seqEvent.Add($"global_{prop.Key}", prop.Value);
|
||||
|
||||
await SendToSeqAsync(seqEvent, cancellationToken);
|
||||
}
|
||||
|
||||
public async Task LogAsync(DependencyTelemetry dep, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var seqEvent = new Dictionary<string, object>
|
||||
{
|
||||
{ "@t", dep.Timestamp.UtcDateTime.ToString("o") },
|
||||
{ "@mt", $"Dependency: {dep.Name}" },
|
||||
{ "@l", dep.Success ?? true ? "Information" : "Error" },
|
||||
{ "Environment", _configuration.Environment },
|
||||
{ "DependencyType", dep.Type },
|
||||
{ "Target", dep.Target },
|
||||
{ "Duration", dep.Duration.TotalMilliseconds }
|
||||
};
|
||||
|
||||
foreach (var prop in dep.Properties)
|
||||
seqEvent.Add($"prop_{prop.Key}", prop.Value);
|
||||
|
||||
foreach (var prop in dep.Context.GlobalProperties)
|
||||
seqEvent.Add($"global_{prop.Key}", prop.Value);
|
||||
|
||||
await SendToSeqAsync(seqEvent, cancellationToken);
|
||||
}
|
||||
|
||||
public async Task LogAsync(RequestTelemetry req, CancellationToken cancellationToken = default)
|
||||
{
|
||||
await Task.CompletedTask;
|
||||
throw new NotImplementedException();
|
||||
}
|
||||
|
||||
public async Task LogAsync(
|
||||
Microsoft.ApplicationInsights.Extensibility.IOperationHolder<RequestTelemetry> operationHolder,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var req = operationHolder.Telemetry;
|
||||
|
||||
//https://docs.datalust.co/v2025.1/docs/posting-raw-events
|
||||
var seqEvent = new Dictionary<string, object>
|
||||
{
|
||||
{ "@t", req.Timestamp.UtcDateTime.ToString("o") },
|
||||
{ "@mt", req.Name },
|
||||
{ "@l", req.Success ?? true ? "Information" : "Error" },
|
||||
{ "@sp", req.Id }, //Span id Unique identifier of a span Yes, if the event is a span
|
||||
{
|
||||
"@tr", req.Context.Operation.Id
|
||||
}, //Trace id An identifier that groups all spans and logs that are in the same trace Yes, if the event is a span
|
||||
{
|
||||
"@sk", "Server"
|
||||
}, //Span kind Describes the relationship of the span to others in the trace: Client, Server, Internal, Producer, or Consumer
|
||||
{
|
||||
"@st", req.Timestamp.UtcDateTime.Subtract(req.Duration).ToString("o")
|
||||
}, //Start The start ISO 8601 timestamp of this span Yes, if the event is a span
|
||||
{ "SourceContext", typeof(T).FullName },
|
||||
{ "Url", req.Url },
|
||||
{ "RequestId", req.Id },
|
||||
{ "ItemTypeFlag", req.ItemTypeFlag.ToString() }
|
||||
};
|
||||
|
||||
|
||||
if (!string.IsNullOrEmpty(req.ResponseCode))
|
||||
if (int.TryParse(req.ResponseCode, out int statusCode))
|
||||
if (Enum.IsDefined(typeof(System.Net.HttpStatusCode), statusCode))
|
||||
seqEvent["StatusCode"] = $"{statusCode} {(System.Net.HttpStatusCode)statusCode}";
|
||||
else
|
||||
seqEvent["StatusCode"] = $"{statusCode} Unknown";
|
||||
|
||||
if (!string.IsNullOrEmpty(req.Context.Operation.ParentId))
|
||||
seqEvent["@ps"] = req.Context.Operation.ParentId;
|
||||
|
||||
if (req.Properties.TryGetValue("httpMethod", out string method))
|
||||
{
|
||||
seqEvent["RequestMethod"] = method;
|
||||
seqEvent["@mt"] = $"{req.Properties["httpMethod"]} {req.Name}";
|
||||
req.Properties.Remove("httpMethod");
|
||||
}
|
||||
|
||||
foreach (var prop in req.Properties)
|
||||
seqEvent.Add($"prop_{prop.Key}", prop.Value);
|
||||
|
||||
foreach (var prop in req.Context.GlobalProperties)
|
||||
seqEvent.Add($"{prop.Key}", prop.Value);
|
||||
|
||||
await SendToSeqAsync(seqEvent, cancellationToken);
|
||||
}
|
||||
|
||||
private async Task SendToSeqAsync(Dictionary<string, object> seqEvent, CancellationToken cancellationToken)
|
||||
{
|
||||
var content = new StringContent(
|
||||
Newtonsoft.Json.JsonConvert.SerializeObject(seqEvent),
|
||||
Encoding.UTF8,
|
||||
"application/vnd.serilog.clef");
|
||||
|
||||
var requestMessage = new HttpRequestMessage(HttpMethod.Post, "/ingest/clef")
|
||||
{
|
||||
Content = content
|
||||
};
|
||||
|
||||
var result = await _httpClient.SendAsync(requestMessage, cancellationToken);
|
||||
|
||||
result.EnsureSuccessStatusCode();
|
||||
}
|
||||
|
||||
private static string MapSeverityToLevel(SeverityLevel? severity)
|
||||
{
|
||||
return severity switch
|
||||
{
|
||||
SeverityLevel.Verbose => "Verbose",
|
||||
SeverityLevel.Information => "Information",
|
||||
SeverityLevel.Warning => "Warning",
|
||||
SeverityLevel.Error => "Error",
|
||||
SeverityLevel.Critical => "Fatal",
|
||||
_ => "Information"
|
||||
};
|
||||
}
|
||||
|
||||
private static string FormatExceptionForSeq(Exception ex)
|
||||
{
|
||||
var sb = new StringBuilder();
|
||||
var exceptionCount = 0;
|
||||
|
||||
void FormatSingleException(Exception currentEx, int depth)
|
||||
{
|
||||
if (depth > 0) sb.AppendLine("\n--- Inner Exception ---");
|
||||
|
||||
sb.AppendLine($"Exception Type: {currentEx.GetType().FullName}");
|
||||
sb.AppendLine($"Message: {currentEx.Message}");
|
||||
sb.AppendLine($"Source: {currentEx.Source}");
|
||||
sb.AppendLine($"HResult: 0x{currentEx.HResult:X8}");
|
||||
sb.AppendLine("Stack Trace:");
|
||||
sb.AppendLine(currentEx.StackTrace?.Trim());
|
||||
|
||||
if (currentEx.Data.Count > 0)
|
||||
{
|
||||
sb.AppendLine("Additional Data:");
|
||||
foreach (var key in currentEx.Data.Keys)
|
||||
sb.AppendLine($" {key}: {currentEx.Data[key]}");
|
||||
}
|
||||
}
|
||||
|
||||
void RecurseExceptions(Exception currentEx, int depth = 0)
|
||||
{
|
||||
if (currentEx is AggregateException aggEx)
|
||||
foreach (var inner in aggEx.InnerExceptions)
|
||||
{
|
||||
RecurseExceptions(inner, depth);
|
||||
depth++;
|
||||
}
|
||||
else if (currentEx.InnerException != null)
|
||||
RecurseExceptions(currentEx.InnerException, depth + 1);
|
||||
|
||||
FormatSingleException(currentEx, depth);
|
||||
exceptionCount++;
|
||||
}
|
||||
|
||||
RecurseExceptions(ex);
|
||||
sb.Insert(0, $"EXCEPTION CHAIN ({exceptionCount} exceptions):\n");
|
||||
return sb.ToString();
|
||||
}
|
||||
}
|
||||
}
|
||||
14
Core/Telemetry/Enrichers/EnrichWithMetaTelemetry.cs
Normal file
14
Core/Telemetry/Enrichers/EnrichWithMetaTelemetry.cs
Normal file
|
|
@ -0,0 +1,14 @@
|
|||
using Microsoft.ApplicationInsights.Channel;
|
||||
using Microsoft.ApplicationInsights.Extensibility;
|
||||
|
||||
namespace SWP.Core.Telemetry.Enrichers
|
||||
{
|
||||
public class EnrichWithMetaTelemetry(ITelemetryProcessor next) : ITelemetryProcessor
|
||||
{
|
||||
public void Process(ITelemetry item)
|
||||
{
|
||||
//nothing going on here yet :)
|
||||
next.Process(item);
|
||||
}
|
||||
}
|
||||
}
|
||||
9
Core/Telemetry/IMessageChannel.cs
Normal file
9
Core/Telemetry/IMessageChannel.cs
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
using System.Threading.Channels;
|
||||
namespace SWP.Core.Telemetry
|
||||
{
|
||||
public interface IMessageChannel<T> : IDisposable
|
||||
{
|
||||
ChannelWriter<T> Writer { get; }
|
||||
ChannelReader<T> Reader { get; }
|
||||
}
|
||||
}
|
||||
23
Core/Telemetry/MessageChannel.cs
Normal file
23
Core/Telemetry/MessageChannel.cs
Normal file
|
|
@ -0,0 +1,23 @@
|
|||
using Microsoft.ApplicationInsights.Channel;
|
||||
using System.Threading.Channels;
|
||||
|
||||
namespace SWP.Core.Telemetry
|
||||
{
|
||||
public class MessageChannel : IMessageChannel<ITelemetry>
|
||||
{
|
||||
private readonly Channel<ITelemetry> _channel;
|
||||
|
||||
public MessageChannel()
|
||||
{
|
||||
_channel = Channel.CreateUnbounded<ITelemetry>();
|
||||
}
|
||||
|
||||
public ChannelWriter<ITelemetry> Writer => _channel.Writer;
|
||||
public ChannelReader<ITelemetry> Reader => _channel.Reader;
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
_channel.Writer.Complete();
|
||||
}
|
||||
}
|
||||
}
|
||||
36
Core/Telemetry/SeqTelemetryChannel.cs
Normal file
36
Core/Telemetry/SeqTelemetryChannel.cs
Normal file
|
|
@ -0,0 +1,36 @@
|
|||
using Microsoft.ApplicationInsights;
|
||||
using Microsoft.ApplicationInsights.Channel;
|
||||
|
||||
namespace SWP.Core.Telemetry
|
||||
{
|
||||
public class SeqTelemetryChannel(IMessageChannel<ITelemetry> messageChannel, TelemetryClient telemetryClient)
|
||||
: InMemoryChannel, ITelemetryChannel
|
||||
{
|
||||
public new void Send(ITelemetry telemetry)
|
||||
{
|
||||
if (telemetry.Context.GlobalProperties.TryGetValue("OmitSeqTelemetryChannel", out var value))
|
||||
if (value == "true")
|
||||
{
|
||||
base.Send(telemetry);
|
||||
return;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var writeTask = messageChannel.Writer.WriteAsync(telemetry).AsTask();
|
||||
writeTask.ContinueWith(t =>
|
||||
{
|
||||
if (t.Exception != null)
|
||||
throw t.Exception;
|
||||
}, TaskContinuationOptions.OnlyOnFaulted);
|
||||
}
|
||||
catch (Exception e)
|
||||
{
|
||||
telemetryClient.TrackException(e,
|
||||
new Dictionary<string, string> { { "OmitSeqTelemetryChannel", "true" } });
|
||||
}
|
||||
|
||||
base.Send(telemetry);
|
||||
}
|
||||
}
|
||||
}
|
||||
12
Core/Telemetry/TelemetryExtensions.cs
Normal file
12
Core/Telemetry/TelemetryExtensions.cs
Normal file
|
|
@ -0,0 +1,12 @@
|
|||
using Newtonsoft.Json;
|
||||
using Newtonsoft.Json.Linq;
|
||||
|
||||
namespace SWP.Core.Telemetry;
|
||||
|
||||
public static class TelemetryExtensions
|
||||
{
|
||||
public static Dictionary<string, string> Format(this object obj)
|
||||
{
|
||||
return new Dictionary<string, string> { { "Object", JObject.FromObject(obj).ToString() } };
|
||||
}
|
||||
}
|
||||
13
Database/Common/Validations.cs
Normal file
13
Database/Common/Validations.cs
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
using System.Text.RegularExpressions;
|
||||
|
||||
namespace PlanTempus.Database.Common
|
||||
{
|
||||
internal class Validations
|
||||
{
|
||||
public static bool IsValidSchemaName(string schema)
|
||||
{
|
||||
return !string.IsNullOrEmpty(schema) && Regex.IsMatch(schema, "^[a-zA-Z0-9_]+$");
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
167
Database/ConfigurationManagementSystem/SetupConfiguration.cs
Normal file
167
Database/ConfigurationManagementSystem/SetupConfiguration.cs
Normal file
|
|
@ -0,0 +1,167 @@
|
|||
using Insight.Database;
|
||||
using PlanTempus.Database.Core;
|
||||
using System.Data;
|
||||
using PlanTempus.Core.Database.ConnectionFactory;
|
||||
|
||||
namespace PlanTempus.Database.ConfigurationManagementSystem;
|
||||
|
||||
public class SetupConfiguration(IDbConnectionFactory connectionFactory) : IDbConfigure<SetupConfiguration.Command>
|
||||
{
|
||||
public class Command { }
|
||||
|
||||
public void With(Command notInUse, ConnectionStringParameters parameters = null)
|
||||
{
|
||||
using var conn = parameters is null ? connectionFactory.Create() : connectionFactory.Create(parameters);
|
||||
using var transaction = conn.OpenWithTransaction();
|
||||
|
||||
try
|
||||
{
|
||||
CreateConfigurationTable(conn);
|
||||
CreateHistoryTable(conn);
|
||||
CreateConfigurationIndexes(conn);
|
||||
CreateModifiedAtTrigger(conn);
|
||||
CreateNotifyTrigger(conn);
|
||||
CreateHistoryTrigger(conn);
|
||||
|
||||
transaction.Commit();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
transaction.Rollback();
|
||||
throw new InvalidOperationException("Failed to SetupConfiguration in Database", ex);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
void CreateConfigurationTable(IDbConnection db)
|
||||
{
|
||||
const string sql = @"
|
||||
CREATE TABLE IF NOT EXISTS app_configuration (
|
||||
id bigserial NOT NULL,
|
||||
""key"" varchar(255) NOT NULL,
|
||||
value text NULL,
|
||||
""label"" varchar(255) NULL,
|
||||
content_type varchar(255) DEFAULT 'text/plain'::character varying NULL,
|
||||
valid_from timestamptz NULL,
|
||||
expires_at timestamptz NULL,
|
||||
created_at timestamptz DEFAULT CURRENT_TIMESTAMP NULL,
|
||||
modified_at timestamptz DEFAULT CURRENT_TIMESTAMP NULL,
|
||||
etag uuid DEFAULT gen_random_uuid() NULL,
|
||||
CONSTRAINT app_configuration_pkey PRIMARY KEY (id)
|
||||
);";
|
||||
db.ExecuteSql(sql);
|
||||
}
|
||||
|
||||
void CreateHistoryTable(IDbConnection db)
|
||||
{
|
||||
const string sql = @"
|
||||
CREATE TABLE IF NOT EXISTS app_configuration_history (
|
||||
history_id bigserial NOT NULL,
|
||||
action_type char(1) NOT NULL,
|
||||
action_timestamp timestamptz NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
action_by text NOT NULL DEFAULT CURRENT_USER,
|
||||
id bigint NOT NULL,
|
||||
""key"" varchar(255) NOT NULL,
|
||||
value text NULL,
|
||||
""label"" varchar(255) NULL,
|
||||
content_type varchar(255) NULL,
|
||||
valid_from timestamptz NULL,
|
||||
expires_at timestamptz NULL,
|
||||
created_at timestamptz NULL,
|
||||
modified_at timestamptz NULL,
|
||||
etag uuid NULL,
|
||||
CONSTRAINT app_configuration_history_pkey PRIMARY KEY (history_id)
|
||||
);";
|
||||
db.ExecuteSql(sql);
|
||||
}
|
||||
|
||||
void CreateConfigurationIndexes(IDbConnection db)
|
||||
{
|
||||
const string sql = @"
|
||||
CREATE INDEX IF NOT EXISTS idx_app_configuration_key ON app_configuration(""key"");
|
||||
CREATE INDEX IF NOT EXISTS idx_app_configuration_validity ON app_configuration(valid_from, expires_at);";
|
||||
db.ExecuteSql(sql);
|
||||
}
|
||||
|
||||
void CreateModifiedAtTrigger(IDbConnection db)
|
||||
{
|
||||
const string sql = @"
|
||||
CREATE OR REPLACE FUNCTION update_app_configuration_modified_at()
|
||||
RETURNS TRIGGER AS $$
|
||||
BEGIN
|
||||
NEW.modified_at = CURRENT_TIMESTAMP;
|
||||
RETURN NEW;
|
||||
END;
|
||||
$$ LANGUAGE plpgsql;
|
||||
|
||||
CREATE OR REPLACE TRIGGER trg_app_configuration_modified_at
|
||||
BEFORE UPDATE ON app_configuration
|
||||
FOR EACH ROW
|
||||
EXECUTE FUNCTION update_app_configuration_modified_at();";
|
||||
db.ExecuteSql(sql);
|
||||
}
|
||||
|
||||
void CreateNotifyTrigger(IDbConnection db)
|
||||
{
|
||||
const string sql = @"
|
||||
CREATE OR REPLACE FUNCTION notify_app_configuration_change()
|
||||
RETURNS TRIGGER AS $$
|
||||
BEGIN
|
||||
PERFORM pg_notify('config_changes', NEW.key);
|
||||
RETURN NEW;
|
||||
END;
|
||||
$$ LANGUAGE plpgsql;
|
||||
|
||||
CREATE OR REPLACE TRIGGER trg_app_configuration_notify
|
||||
AFTER INSERT OR UPDATE ON app_configuration
|
||||
FOR EACH ROW
|
||||
EXECUTE FUNCTION notify_app_configuration_change();";
|
||||
db.ExecuteSql(sql);
|
||||
}
|
||||
|
||||
void CreateHistoryTrigger(IDbConnection db)
|
||||
{
|
||||
const string sql = @"
|
||||
CREATE OR REPLACE FUNCTION log_app_configuration_changes()
|
||||
RETURNS TRIGGER AS $$
|
||||
BEGIN
|
||||
IF (TG_OP = 'INSERT') THEN
|
||||
INSERT INTO app_configuration_history (
|
||||
action_type, id, ""key"", value, label, content_type,
|
||||
valid_from, expires_at, created_at, modified_at, etag
|
||||
)
|
||||
VALUES (
|
||||
'I', NEW.id, NEW.key, NEW.value, NEW.label, NEW.content_type,
|
||||
NEW.valid_from, NEW.expires_at, NEW.created_at, NEW.modified_at, NEW.etag
|
||||
);
|
||||
ELSIF (TG_OP = 'UPDATE') THEN
|
||||
INSERT INTO app_configuration_history (
|
||||
action_type, id, ""key"", value, label, content_type,
|
||||
valid_from, expires_at, created_at, modified_at, etag
|
||||
)
|
||||
VALUES (
|
||||
'U', OLD.id, OLD.key, OLD.value, OLD.label, OLD.content_type,
|
||||
OLD.valid_from, OLD.expires_at, OLD.created_at, OLD.modified_at, OLD.etag
|
||||
);
|
||||
ELSIF (TG_OP = 'DELETE') THEN
|
||||
INSERT INTO app_configuration_history (
|
||||
action_type, id, ""key"", value, label, content_type,
|
||||
valid_from, expires_at, created_at, modified_at, etag
|
||||
)
|
||||
VALUES (
|
||||
'D', OLD.id, OLD.key, OLD.value, OLD.label, OLD.content_type,
|
||||
OLD.valid_from, OLD.expires_at, OLD.created_at, OLD.modified_at, OLD.etag
|
||||
);
|
||||
END IF;
|
||||
RETURN NULL;
|
||||
END;
|
||||
$$ LANGUAGE plpgsql;
|
||||
|
||||
CREATE OR REPLACE TRIGGER trg_app_configuration_history
|
||||
AFTER INSERT OR UPDATE OR DELETE ON app_configuration
|
||||
FOR EACH ROW EXECUTE FUNCTION log_app_configuration_changes();";
|
||||
db.ExecuteSql(sql);
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
106
Database/Core/DCL/SetupApplicationUser.cs
Normal file
106
Database/Core/DCL/SetupApplicationUser.cs
Normal file
|
|
@ -0,0 +1,106 @@
|
|||
using System.Data;
|
||||
using Insight.Database;
|
||||
using PlanTempus.Core.Database.ConnectionFactory;
|
||||
using PlanTempus.Database.Common;
|
||||
|
||||
namespace PlanTempus.Database.Core.DCL
|
||||
{
|
||||
/// <summary>
|
||||
/// Only a superadmin or similar can create Application Users
|
||||
/// </summary>
|
||||
public class SetupApplicationUser : IDbConfigure<SetupApplicationUser.Command>
|
||||
{
|
||||
public class Command
|
||||
{
|
||||
public required string Schema { get; init; }
|
||||
public required string User { get; init; }
|
||||
public required string Password { get; init; }
|
||||
}
|
||||
|
||||
Command _command;
|
||||
private readonly IDbConnectionFactory _connectionFactory;
|
||||
|
||||
public SetupApplicationUser(IDbConnectionFactory connectionFactory)
|
||||
{
|
||||
_connectionFactory = connectionFactory;
|
||||
}
|
||||
|
||||
public void With(Command command, ConnectionStringParameters parameters = null)
|
||||
{
|
||||
_command = command;
|
||||
|
||||
if (!Validations.IsValidSchemaName(_command.Schema))
|
||||
throw new ArgumentException("Invalid schema name", _command.Schema);
|
||||
|
||||
using var conn = parameters is null ? _connectionFactory.Create() : _connectionFactory.Create(parameters);
|
||||
using var transaction = conn.OpenWithTransaction();
|
||||
try
|
||||
{
|
||||
CreateSchema(conn);
|
||||
CreateRole(conn);
|
||||
GrantSchemaRights(conn);
|
||||
|
||||
transaction.Commit();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
transaction.Rollback();
|
||||
throw new InvalidOperationException("Failed to SetupApplicationUser in Database", ex);
|
||||
}
|
||||
}
|
||||
|
||||
private void CreateSchema(IDbConnection db)
|
||||
{
|
||||
var sql = $"CREATE SCHEMA IF NOT EXISTS {_command.Schema}";
|
||||
db.ExecuteSql(sql);
|
||||
}
|
||||
|
||||
private void CreateRole(IDbConnection db)
|
||||
{
|
||||
var sql = $@"
|
||||
DO $$
|
||||
BEGIN
|
||||
IF NOT EXISTS (SELECT FROM pg_roles WHERE rolname = '{_command.User}') THEN
|
||||
CREATE ROLE {_command.User} WITH CREATEDB CREATEROLE LOGIN PASSWORD '{_command.Password}';
|
||||
END IF;
|
||||
END $$;";
|
||||
db.ExecuteSql(sql);
|
||||
|
||||
var sql1 = $"ALTER ROLE {_command.User} SET search_path='{_command.Schema}';";
|
||||
db.ExecuteSql(sql1);
|
||||
}
|
||||
|
||||
private void GrantSchemaRights(IDbConnection db)
|
||||
{
|
||||
// Grant USAGE og alle CREATE rettigheder på schema niveau
|
||||
var sql = $@"
|
||||
GRANT USAGE ON SCHEMA {_command.Schema} TO {_command.User};
|
||||
GRANT ALL ON SCHEMA {_command.Schema} TO {_command.User};";
|
||||
db.ExecuteSql(sql);
|
||||
|
||||
// Grant rettigheder på eksisterende og fremtidige tabeller
|
||||
var sql1 = $"GRANT ALL PRIVILEGES ON ALL TABLES IN SCHEMA {_command.Schema} TO {_command.User};";
|
||||
db.ExecuteSql(sql1);
|
||||
|
||||
var sql2 = $"ALTER DEFAULT PRIVILEGES IN SCHEMA {_command.Schema} GRANT ALL PRIVILEGES ON TABLES TO {_command.User};";
|
||||
db.ExecuteSql(sql2);
|
||||
|
||||
// Grant sequence rettigheder
|
||||
var sql3 = $"GRANT ALL PRIVILEGES ON ALL SEQUENCES IN SCHEMA {_command.Schema} TO {_command.User};";
|
||||
db.ExecuteSql(sql3);
|
||||
|
||||
// Grant execute på functions
|
||||
var sql4 = $"GRANT EXECUTE ON ALL FUNCTIONS IN SCHEMA {_command.Schema} TO {_command.User};";
|
||||
db.ExecuteSql(sql4);
|
||||
|
||||
// Grant for fremtidige functions
|
||||
var sql5 = $"ALTER DEFAULT PRIVILEGES IN SCHEMA {_command.Schema} GRANT EXECUTE ON FUNCTIONS TO {_command.User};";
|
||||
db.ExecuteSql(sql5);
|
||||
|
||||
// Grant for fremtidige sequences
|
||||
var sql6 = $"ALTER DEFAULT PRIVILEGES IN SCHEMA {_command.Schema} GRANT USAGE ON SEQUENCES TO {_command.User};";
|
||||
db.ExecuteSql(sql6);
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
89
Database/Core/DCL/SetupDbAdmin.cs
Normal file
89
Database/Core/DCL/SetupDbAdmin.cs
Normal file
|
|
@ -0,0 +1,89 @@
|
|||
using System.Data;
|
||||
using Insight.Database;
|
||||
using PlanTempus.Core.Database.ConnectionFactory;
|
||||
using PlanTempus.Database.Common;
|
||||
|
||||
namespace PlanTempus.Database.Core.DCL
|
||||
{
|
||||
|
||||
/// <summary>
|
||||
/// Only a superadmin or similar can create Application Users
|
||||
/// </summary>
|
||||
public class SetupDbAdmin : IDbConfigure<SetupDbAdmin.Command>
|
||||
{
|
||||
public class Command
|
||||
{
|
||||
public required string Schema { get; init; }
|
||||
public required string User { get; init; }
|
||||
public required string Password { get; init; }
|
||||
}
|
||||
|
||||
|
||||
Command _command;
|
||||
private readonly IDbConnectionFactory _connectionFactory;
|
||||
|
||||
public SetupDbAdmin(IDbConnectionFactory connectionFactory)
|
||||
{
|
||||
_connectionFactory = connectionFactory;
|
||||
}
|
||||
|
||||
|
||||
public void With(Command command, ConnectionStringParameters parameters = null)
|
||||
{
|
||||
_command = command;
|
||||
|
||||
if (!Validations.IsValidSchemaName(_command.Schema))
|
||||
throw new ArgumentException("Invalid schema name", _command.Schema);
|
||||
|
||||
using var conn = parameters is null ? _connectionFactory.Create() : _connectionFactory.Create(parameters);
|
||||
using var transaction = conn.OpenWithTransaction();
|
||||
try
|
||||
{
|
||||
CreateSchema(conn);
|
||||
CreateRole(conn);
|
||||
GrantSchemaRights(conn);
|
||||
|
||||
transaction.Commit();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
transaction.Rollback();
|
||||
throw new InvalidOperationException("Failed to SetupApplicationUser in Database", ex);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
private void CreateSchema(IDbConnection db)
|
||||
{
|
||||
var sql = $"CREATE SCHEMA IF NOT EXISTS {_command.Schema}";
|
||||
db.ExecuteSql(sql);
|
||||
}
|
||||
|
||||
private void CreateRole(IDbConnection db)
|
||||
{
|
||||
var sql = $@"
|
||||
DO $$
|
||||
BEGIN
|
||||
IF NOT EXISTS (SELECT FROM pg_roles WHERE rolname = '{_command.User}') THEN
|
||||
CREATE ROLE {_command.User} WITH CREATEDB CREATEROLE LOGIN PASSWORD '{_command.Password}';
|
||||
END IF;
|
||||
END $$;";
|
||||
db.ExecuteSql(sql);
|
||||
|
||||
var sql1 = $"ALTER ROLE {_command.User} SET search_path='{_command.Schema}';";
|
||||
db.ExecuteSql(sql1);
|
||||
|
||||
var sql2 = $"ALTER SCHEMA {_command.Schema} OWNER TO {_command.User};";
|
||||
db.ExecuteSql(sql2);
|
||||
|
||||
}
|
||||
|
||||
private void GrantSchemaRights(IDbConnection db)
|
||||
{
|
||||
var sql = $@"GRANT CREATE ON SCHEMA {_command.Schema} TO {_command.User};";
|
||||
|
||||
db.ExecuteSql(sql);
|
||||
|
||||
}
|
||||
}
|
||||
}
|
||||
89
Database/Core/DCL/SetupOrganizationUser.cs
Normal file
89
Database/Core/DCL/SetupOrganizationUser.cs
Normal file
|
|
@ -0,0 +1,89 @@
|
|||
using System.Data;
|
||||
using Insight.Database;
|
||||
using PlanTempus.Core.Database.ConnectionFactory;
|
||||
using PlanTempus.Database.Common;
|
||||
using PlanTempus.Database.Core;
|
||||
|
||||
namespace PlanTempus.Database.Core.DCL
|
||||
{
|
||||
public class SetupOrganization : IDbConfigure<SetupOrganization.Command>
|
||||
{
|
||||
public class Command
|
||||
{
|
||||
public required string Schema { get; init; }
|
||||
public required string User { get; init; }
|
||||
public required string Password { get; init; }
|
||||
}
|
||||
|
||||
Command _command;
|
||||
private readonly IDbConnectionFactory _connectionFactory;
|
||||
|
||||
|
||||
public SetupOrganization(IDbConnectionFactory connectionFactory)
|
||||
{
|
||||
_connectionFactory = connectionFactory;
|
||||
}
|
||||
|
||||
public void With(Command command, ConnectionStringParameters parameters = null)
|
||||
{
|
||||
_command = command;
|
||||
|
||||
if (!Validations.IsValidSchemaName(_command.Schema))
|
||||
throw new ArgumentException("Invalid schema name", _command.Schema);
|
||||
|
||||
using var conn = parameters is null ? _connectionFactory.Create() : _connectionFactory.Create(parameters);
|
||||
using var transaction = conn.OpenWithTransaction();
|
||||
try
|
||||
{
|
||||
CreateSchema(conn);
|
||||
CreateRole(conn);
|
||||
GrantSchemaRights(conn);
|
||||
|
||||
transaction.Commit();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
transaction.Rollback();
|
||||
throw new InvalidOperationException("Failed to SetupOrganization in Database", ex);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
private void CreateSchema(IDbConnection db)
|
||||
{
|
||||
var sql = $"CREATE SCHEMA IF NOT EXISTS {_command.Schema}";
|
||||
db.ExecuteSql(sql);
|
||||
}
|
||||
|
||||
private void CreateRole(IDbConnection db)
|
||||
{
|
||||
var sql = $"CREATE ROLE {_command.User} LOGIN PASSWORD '{_command.Password}';";
|
||||
db.ExecuteSql(sql);
|
||||
|
||||
var sql1 = $"ALTER ROLE {_command.User} SET search_path='{_command.Schema}';";
|
||||
db.ExecuteSql(sql1);
|
||||
|
||||
}
|
||||
|
||||
private void GrantSchemaRights(IDbConnection db)
|
||||
{
|
||||
var sql = $"GRANT USAGE ON SCHEMA {_command.Schema} TO {_command.User};";
|
||||
db.ExecuteSql(sql);
|
||||
|
||||
var sql1 = $"ALTER DEFAULT PRIVILEGES IN SCHEMA {_command.Schema} " +
|
||||
$"GRANT INSERT, SELECT, UPDATE PRIVILEGES ON TABLES TO {_command.User};";
|
||||
db.ExecuteSql(sql1);
|
||||
|
||||
var sql2 = $"GRANT ALL PRIVILEGES ON ALL SEQUENCES IN SCHEMA {_command.Schema} TO {_command.User};";
|
||||
db.ExecuteSql(sql2);
|
||||
|
||||
var sql3 = $"GRANT CREATE TABLE ON SCHEMA {_command.Schema} TO {_command.User};";
|
||||
db.ExecuteSql(sql3);
|
||||
}
|
||||
public void RevokeCreateTable(IDbConnection db)
|
||||
{
|
||||
var sql = $"REVOKE CREATE TABLE ON SCHEMA {_command.Schema} FROM {_command.User};";
|
||||
db.ExecuteSql(sql);
|
||||
}
|
||||
}
|
||||
}
|
||||
140
Database/Core/DDL/SetupIdentitySystem.cs
Normal file
140
Database/Core/DDL/SetupIdentitySystem.cs
Normal file
|
|
@ -0,0 +1,140 @@
|
|||
using Insight.Database;
|
||||
using System.Data;
|
||||
using PlanTempus.Core.Database.ConnectionFactory;
|
||||
|
||||
namespace PlanTempus.Database.Core.DDL
|
||||
{
|
||||
/// <summary>
|
||||
/// This is by purpose not async await
|
||||
/// It is intended that this is created with the correct Application User, which is why the schema name is omitted.
|
||||
/// </summary>
|
||||
public class SetupIdentitySystem : IDbConfigure<SetupIdentitySystem.Command>
|
||||
{
|
||||
public class Command
|
||||
{
|
||||
public required string Schema { get; init; }
|
||||
}
|
||||
|
||||
Command _command;
|
||||
private readonly IDbConnectionFactory _connectionFactory;
|
||||
|
||||
public SetupIdentitySystem(IDbConnectionFactory connectionFactory)
|
||||
{
|
||||
_connectionFactory = connectionFactory;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates the system tables in the specified schema within a transaction.
|
||||
/// </summary>
|
||||
/// <param name="schema">The schema name where the tables will be created.</param>
|
||||
public void With(Command command, ConnectionStringParameters parameters = null)
|
||||
{
|
||||
_command = command;
|
||||
|
||||
using var conn = parameters is null ? _connectionFactory.Create() : _connectionFactory.Create(parameters);
|
||||
using var transaction = conn.OpenWithTransaction();
|
||||
try
|
||||
{
|
||||
CreateUsersTable(conn);
|
||||
CreateOrganizationsTable(conn);
|
||||
CreateUserOrganizationsTable(conn);
|
||||
SetupRLS(conn);
|
||||
|
||||
transaction.Commit();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
transaction.Rollback();
|
||||
throw new InvalidOperationException("Failed to SetupIdentitySystem. Transaction is rolled back", ex);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/// <summary>
|
||||
/// Creates the users table
|
||||
/// </summary>
|
||||
void CreateUsersTable(IDbConnection db)
|
||||
{
|
||||
var sql = @$"
|
||||
CREATE TABLE IF NOT EXISTS {_command.Schema}.users (
|
||||
id SERIAL PRIMARY KEY,
|
||||
email VARCHAR(256) NOT NULL UNIQUE,
|
||||
password_hash VARCHAR(256) NOT NULL,
|
||||
security_stamp VARCHAR(36) NOT NULL,
|
||||
email_confirmed BOOLEAN NOT NULL DEFAULT FALSE,
|
||||
access_failed_count INTEGER NOT NULL DEFAULT 0,
|
||||
lockout_enabled BOOLEAN NOT NULL DEFAULT TRUE,
|
||||
lockout_end TIMESTAMPTZ NULL,
|
||||
is_active BOOLEAN NOT NULL DEFAULT TRUE,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
last_login_at TIMESTAMPTZ NULL
|
||||
);";
|
||||
|
||||
db.ExecuteSql(sql);
|
||||
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates the organizations table
|
||||
/// </summary>
|
||||
void CreateOrganizationsTable(IDbConnection db)
|
||||
{
|
||||
var sql = @$"
|
||||
CREATE TABLE IF NOT EXISTS {_command.Schema}.organizations (
|
||||
id SERIAL PRIMARY KEY,
|
||||
connection_string VARCHAR(500) NOT NULL,
|
||||
is_active BOOLEAN NOT NULL DEFAULT TRUE,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
);";
|
||||
|
||||
db.ExecuteSql(sql);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates the user_organizations table
|
||||
/// </summary>
|
||||
void CreateUserOrganizationsTable(IDbConnection db)
|
||||
{
|
||||
var sql = @$"
|
||||
CREATE TABLE IF NOT EXISTS {_command.Schema}.user_organizations (
|
||||
user_id INTEGER NOT NULL REFERENCES {_command.Schema}.users(id),
|
||||
organization_id INTEGER NOT NULL REFERENCES {_command.Schema}.organizations(id),
|
||||
pin_code VARCHAR(10) NULL,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
PRIMARY KEY (user_id, organization_id)
|
||||
);";
|
||||
|
||||
db.ExecuteSql(sql);
|
||||
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Sets up Row Level Security (RLS) for the organizations and user_organizations tables.
|
||||
/// </summary>
|
||||
void SetupRLS(IDbConnection db)
|
||||
{
|
||||
var sql = new[]
|
||||
{
|
||||
$"ALTER TABLE {_command.Schema}.organizations ENABLE ROW LEVEL SECURITY;",
|
||||
$"ALTER TABLE {_command.Schema}.user_organizations ENABLE ROW LEVEL SECURITY;",
|
||||
$"DROP POLICY IF EXISTS organization_access ON {_command.Schema}.organizations;",
|
||||
@$"CREATE POLICY organization_access ON {_command.Schema}.organizations
|
||||
USING (id IN (
|
||||
SELECT organization_id
|
||||
FROM {_command.Schema}.user_organizations
|
||||
WHERE user_id = current_setting('app.user_id', TRUE)::INTEGER
|
||||
)) WITH CHECK (true);",
|
||||
$"DROP POLICY IF EXISTS user_organization_access ON {_command.Schema}.user_organizations;",
|
||||
@$"CREATE POLICY user_organization_access ON {_command.Schema}.user_organizations
|
||||
USING (user_id = current_setting('app.user_id', TRUE)::INTEGER) WITH CHECK (true);"
|
||||
};
|
||||
|
||||
foreach (var statement in sql)
|
||||
{
|
||||
db.ExecuteSql(statement);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
}
|
||||
9
Database/Core/IDbConfigure.cs
Normal file
9
Database/Core/IDbConfigure.cs
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
using PlanTempus.Core.Database.ConnectionFactory;
|
||||
|
||||
namespace PlanTempus.Database.Core
|
||||
{
|
||||
public interface IDbConfigure<T>
|
||||
{
|
||||
void With(T command, ConnectionStringParameters parameters = null);
|
||||
}
|
||||
}
|
||||
79
Database/Core/UserService.cs
Normal file
79
Database/Core/UserService.cs
Normal file
|
|
@ -0,0 +1,79 @@
|
|||
using Insight.Database;
|
||||
using PlanTempus.Core;
|
||||
using PlanTempus.Core.Entities.Users;
|
||||
using System.Data;
|
||||
|
||||
namespace PlanTempus.Database.Core
|
||||
{
|
||||
public class UserService
|
||||
{
|
||||
public record UserCreateCommand(string CorrelationId, string Email, string Password);
|
||||
|
||||
private readonly IDbConnection _db;
|
||||
|
||||
public UserService(IDbConnection db)
|
||||
{
|
||||
_db = db;
|
||||
}
|
||||
|
||||
public async Task CreateUser(UserCreateCommand command)
|
||||
{
|
||||
var user = new User
|
||||
{
|
||||
Email = command.Email,
|
||||
PasswordHash = new SecureTokenizer().TokenizeText(command.Password),
|
||||
SecurityStamp = Guid.NewGuid().ToString(),
|
||||
EmailConfirmed = false,
|
||||
CreatedDate = DateTime.UtcNow
|
||||
};
|
||||
|
||||
var userId = await _db.ExecuteScalarAsync<int>(@$"
|
||||
INSERT INTO users (email, password_hash, security_stamp, email_confirmed, created_at)
|
||||
VALUES (@Email, @PasswordHash, @SecurityStamp, @EmailConfirmed, @CreatedDate)
|
||||
RETURNING id", user);
|
||||
}
|
||||
|
||||
public async Task CreateOrganization(int userId, string organizationConnectionString)
|
||||
{
|
||||
var schema = "dev";
|
||||
|
||||
|
||||
using var transaction = _db.OpenWithTransaction();
|
||||
try
|
||||
{
|
||||
// Create organization
|
||||
var organization = new Organization
|
||||
{
|
||||
ConnectionString = organizationConnectionString,
|
||||
CreatedDate = DateTime.UtcNow,
|
||||
CreatedBy = userId,
|
||||
IsActive = true
|
||||
};
|
||||
|
||||
var organizationId = await _db.ExecuteScalarAsync<int>(@$"
|
||||
INSERT INTO {schema}.organizations (connection_string, created_date, is_active)
|
||||
VALUES (@ConnectionString, @CreatedDate, @IsActive)
|
||||
RETURNING id", organization);
|
||||
|
||||
// Link user to organization
|
||||
var userOrganization = new UserOrganization
|
||||
{
|
||||
UserId = userId,
|
||||
OrganizationId = organizationId,
|
||||
CreatedDate = DateTime.UtcNow
|
||||
};
|
||||
|
||||
await _db.ExecuteAsync(@$"
|
||||
INSERT INTO {schema}.user_organizations (user_id, organization_id, created_date)
|
||||
VALUES (@UserId, @OrganizationId, @CreatedDate)", userOrganization);
|
||||
|
||||
transaction.Commit();
|
||||
}
|
||||
catch
|
||||
{
|
||||
transaction.Rollback();
|
||||
throw;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
26
Database/ModuleRegistry/DbPostgreSqlModule.cs
Normal file
26
Database/ModuleRegistry/DbPostgreSqlModule.cs
Normal file
|
|
@ -0,0 +1,26 @@
|
|||
using Autofac;
|
||||
using PlanTempus.Core.Database;
|
||||
using PlanTempus.Core.Database.ConnectionFactory;
|
||||
|
||||
namespace PlanTempus.Database.ModuleRegistry
|
||||
{
|
||||
|
||||
public class DbPostgreSqlModule : Module
|
||||
{
|
||||
public required string ConnectionString { get; set; }
|
||||
|
||||
protected override void Load(ContainerBuilder builder)
|
||||
{
|
||||
Insight.Database.Providers.PostgreSQL.PostgreSQLInsightDbProvider.RegisterProvider();
|
||||
|
||||
builder.RegisterType<PostgresConnectionFactory>()
|
||||
.As<IDbConnectionFactory>()
|
||||
.WithParameter(new TypedParameter(typeof(string), ConnectionString))
|
||||
.SingleInstance();
|
||||
|
||||
builder.RegisterType<SqlOperations>()
|
||||
.As<IDatabaseOperations>();
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
50
Database/NavigationSystem/Setup.cs
Normal file
50
Database/NavigationSystem/Setup.cs
Normal file
|
|
@ -0,0 +1,50 @@
|
|||
using Insight.Database;
|
||||
using System.Data;
|
||||
|
||||
namespace PlanTempus.Database.NavigationSystem
|
||||
{
|
||||
internal class Setup
|
||||
{
|
||||
|
||||
private readonly IDbConnection _db;
|
||||
|
||||
public Setup(IDbConnection db)
|
||||
{
|
||||
_db = db;
|
||||
}
|
||||
public void CreateSystem()
|
||||
{
|
||||
//await CreateNavigationLinkTemplatesTable(schema);
|
||||
//await CreateNavigationLinkTemplateTranslationsTable(schema);
|
||||
}
|
||||
|
||||
private async Task CreateNavigationLinkTemplatesTable()
|
||||
{
|
||||
var sql = $@"
|
||||
CREATE TABLE IF NOT EXISTS navigation_link_templates (
|
||||
id SERIAL PRIMARY KEY,
|
||||
parent_id INTEGER NULL,
|
||||
url VARCHAR(500) NOT NULL,
|
||||
permission_id INTEGER NULL,
|
||||
icon VARCHAR(100) NULL,
|
||||
default_order INTEGER NOT NULL,
|
||||
FOREIGN KEY (permission_id) REFERENCES permissions(id),
|
||||
FOREIGN KEY (parent_id) REFERENCES navigation_link_templates(id)
|
||||
)";
|
||||
await _db.ExecuteAsync(sql);
|
||||
}
|
||||
|
||||
private async Task CreateNavigationLinkTemplateTranslationsTable(string schema)
|
||||
{
|
||||
var sql = $@"
|
||||
CREATE TABLE IF NOT EXISTS navigation_link_template_translations (
|
||||
id SERIAL PRIMARY KEY,
|
||||
template_id INTEGER NOT NULL,
|
||||
language VARCHAR(10) NOT NULL,
|
||||
display_name VARCHAR(100) NOT NULL,
|
||||
FOREIGN KEY (template_id) REFERENCES navigation_link_templates(id)
|
||||
)";
|
||||
await _db.ExecuteAsync(sql);
|
||||
}
|
||||
}
|
||||
}
|
||||
16
Database/PlanTempus.Database.csproj
Normal file
16
Database/PlanTempus.Database.csproj
Normal file
|
|
@ -0,0 +1,16 @@
|
|||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net9.0</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\Core\PlanTempus.Core.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<Folder Include="AuditSystem\" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
96
Database/RolesPermissionSystem/Setup.cs
Normal file
96
Database/RolesPermissionSystem/Setup.cs
Normal file
|
|
@ -0,0 +1,96 @@
|
|||
using System.Data;
|
||||
using Insight.Database;
|
||||
|
||||
namespace PlanTempus.Database.RolesPermissionSystem
|
||||
{
|
||||
/// <summary>
|
||||
/// This is by purpose not async await
|
||||
/// It is intended that this is created with the correct Application User, which is why the schema name is omitted.
|
||||
/// </summary>
|
||||
public class Setup
|
||||
{
|
||||
IDbConnection _db;
|
||||
|
||||
public Setup(IDbConnection db)
|
||||
{
|
||||
_db = db;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates the system tables in the specified schema within a transaction.
|
||||
/// </summary>
|
||||
/// <param name="schema">The schema name where the tables will be created.</param>
|
||||
public void CreateSystem()
|
||||
{
|
||||
//if (!Validations.IsValidSchemaName(_schema))
|
||||
// throw new ArgumentException("Invalid schema name", _schema);
|
||||
|
||||
using var transaction = _db.BeginTransaction();
|
||||
try
|
||||
{
|
||||
CreateRolesTable();
|
||||
CreatePermissionsTable();
|
||||
CreatePermissionTypesTable();
|
||||
CreateRolePermissionsTable();
|
||||
|
||||
transaction.Commit();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
transaction.Rollback();
|
||||
throw new InvalidOperationException("Failed to create system tables.", ex);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
private void ExecuteSql(string sql)
|
||||
{
|
||||
_db.ExecuteSql(sql);
|
||||
}
|
||||
|
||||
private void CreatePermissionTypesTable()
|
||||
{
|
||||
var sql = $@"
|
||||
CREATE TABLE IF NOT EXISTS permission_types (
|
||||
id SERIAL PRIMARY KEY,
|
||||
name VARCHAR(100) NOT NULL UNIQUE
|
||||
)";
|
||||
ExecuteSql(sql);
|
||||
}
|
||||
|
||||
private void CreatePermissionsTable()
|
||||
{
|
||||
var sql = $@"
|
||||
CREATE TABLE IF NOT EXISTS permissions (
|
||||
id SERIAL PRIMARY KEY,
|
||||
name VARCHAR(100) NOT NULL UNIQUE,
|
||||
type_id INTEGER NOT NULL,
|
||||
FOREIGN KEY (type_id) REFERENCES permission_types(id)
|
||||
)";
|
||||
ExecuteSql(sql);
|
||||
}
|
||||
|
||||
private void CreateRolesTable()
|
||||
{
|
||||
var sql = $@"
|
||||
CREATE TABLE IF NOT EXISTS roles (
|
||||
id SERIAL PRIMARY KEY,
|
||||
name VARCHAR(100) NOT NULL UNIQUE
|
||||
)";
|
||||
ExecuteSql(sql);
|
||||
}
|
||||
|
||||
private void CreateRolePermissionsTable()
|
||||
{
|
||||
var sql = $@"
|
||||
CREATE TABLE IF NOT EXISTS role_permissions (
|
||||
role_id INTEGER NOT NULL,
|
||||
permission_id INTEGER NOT NULL,
|
||||
PRIMARY KEY (role_id, permission_id),
|
||||
FOREIGN KEY (role_id) REFERENCES roles(id),
|
||||
FOREIGN KEY (permission_id) REFERENCES permissions(id)
|
||||
)";
|
||||
ExecuteSql(sql);
|
||||
}
|
||||
}
|
||||
}
|
||||
138
Database/Tenants/InitializeTenantData.cs
Normal file
138
Database/Tenants/InitializeTenantData.cs
Normal file
|
|
@ -0,0 +1,138 @@
|
|||
using Insight.Database;
|
||||
using System.Data;
|
||||
|
||||
namespace PlanTempus.Database.Tenants
|
||||
{
|
||||
internal class InitializeOrganizationData
|
||||
{
|
||||
private readonly IDbConnection _db;
|
||||
|
||||
public InitializeOrganizationData(IDbConnection db)
|
||||
{
|
||||
_db = db;
|
||||
}
|
||||
|
||||
private async Task InsertInitialData(string schema)
|
||||
{
|
||||
// Permission types
|
||||
var insertPermissionTypes = $@"
|
||||
INSERT INTO {schema}.permission_types (name) VALUES
|
||||
('NAVIGATION'),
|
||||
('COMMAND'),
|
||||
('VIEW'),
|
||||
('FEATURE')";
|
||||
await _db.ExecuteAsync(insertPermissionTypes);
|
||||
|
||||
// Permissions
|
||||
var insertPermissions = $@"
|
||||
INSERT INTO {schema}.permissions (name, type_id) VALUES
|
||||
-- Navigation permissions
|
||||
('OVERVIEW_VIEW', (SELECT id FROM {schema}.permission_types WHERE name = 'NAVIGATION')),
|
||||
('CALENDAR_VIEW', (SELECT id FROM {schema}.permission_types WHERE name = 'NAVIGATION')),
|
||||
('SALES_VIEW', (SELECT id FROM {schema}.permission_types WHERE name = 'NAVIGATION')),
|
||||
('CUSTOMERS_VIEW', (SELECT id FROM {schema}.permission_types WHERE name = 'NAVIGATION')),
|
||||
|
||||
-- Command permissions
|
||||
('CREATE_PRODUCT', (SELECT id FROM {schema}.permission_types WHERE name = 'COMMAND')),
|
||||
('EDIT_PRODUCT', (SELECT id FROM {schema}.permission_types WHERE name = 'COMMAND')),
|
||||
('DELETE_PRODUCT', (SELECT id FROM {schema}.permission_types WHERE name = 'COMMAND')),
|
||||
('CREATE_CUSTOMER', (SELECT id FROM {schema}.permission_types WHERE name = 'COMMAND')),
|
||||
('EDIT_CUSTOMER', (SELECT id FROM {schema}.permission_types WHERE name = 'COMMAND')),
|
||||
|
||||
-- View permissions
|
||||
('PRODUCT_DETAILS', (SELECT id FROM {schema}.permission_types WHERE name = 'VIEW')),
|
||||
('CUSTOMER_DETAILS', (SELECT id FROM {schema}.permission_types WHERE name = 'VIEW')),
|
||||
('SALES_STATISTICS', (SELECT id FROM {schema}.permission_types WHERE name = 'VIEW')),
|
||||
|
||||
-- Feature permissions
|
||||
('ADVANCED_SEARCH', (SELECT id FROM {schema}.permission_types WHERE name = 'FEATURE')),
|
||||
('EXPORT_DATA', (SELECT id FROM {schema}.permission_types WHERE name = 'FEATURE')),
|
||||
('BULK_OPERATIONS', (SELECT id FROM {schema}.permission_types WHERE name = 'FEATURE'))";
|
||||
await _db.ExecuteAsync(insertPermissions);
|
||||
|
||||
// Roles
|
||||
var insertRoles = $@"
|
||||
INSERT INTO {schema}.roles (name) VALUES
|
||||
('SYSTEM_ADMIN'),
|
||||
('TENANT_ADMIN'),
|
||||
('POWER_USER'),
|
||||
('BASIC_USER')";
|
||||
await _db.ExecuteAsync(insertRoles);
|
||||
|
||||
// Top-level navigation
|
||||
var insertTopNav = $@"
|
||||
INSERT INTO {schema}.navigation_link_templates
|
||||
(parent_id, url, permission_id, icon, default_order)
|
||||
VALUES
|
||||
(NULL, '/overview',
|
||||
(SELECT id FROM {schema}.permissions WHERE name = 'OVERVIEW_VIEW'),
|
||||
'home', 10),
|
||||
(NULL, '/sales',
|
||||
(SELECT id FROM {schema}.permissions WHERE name = 'SALES_VIEW'),
|
||||
'shopping-cart', 20),
|
||||
(NULL, '/customers',
|
||||
(SELECT id FROM {schema}.permissions WHERE name = 'CUSTOMERS_VIEW'),
|
||||
'users', 30)";
|
||||
await _db.ExecuteAsync(insertTopNav);
|
||||
|
||||
// Sub-navigation
|
||||
var insertSubNav = $@"
|
||||
INSERT INTO {schema}.navigation_link_templates
|
||||
(parent_id, url, permission_id, icon, default_order)
|
||||
VALUES
|
||||
((SELECT id FROM {schema}.navigation_link_templates WHERE url = '/sales'),
|
||||
'/sales/create',
|
||||
(SELECT id FROM {schema}.permissions WHERE name = 'CREATE_PRODUCT'),
|
||||
'plus', 1),
|
||||
((SELECT id FROM {schema}.navigation_link_templates WHERE url = '/customers'),
|
||||
'/customers/create',
|
||||
(SELECT id FROM {schema}.permissions WHERE name = 'CREATE_CUSTOMER'),
|
||||
'user-plus', 1)";
|
||||
await _db.ExecuteAsync(insertSubNav);
|
||||
|
||||
// Translations for top-level
|
||||
var insertTopTranslations = $@"
|
||||
INSERT INTO {schema}.navigation_link_template_translations
|
||||
(template_id, language, display_name)
|
||||
VALUES
|
||||
((SELECT id FROM {schema}.navigation_link_templates WHERE url = '/overview'),
|
||||
'da-DK', 'Overblik'),
|
||||
((SELECT id FROM {schema}.navigation_link_templates WHERE url = '/overview'),
|
||||
'en-US', 'Overview'),
|
||||
((SELECT id FROM {schema}.navigation_link_templates WHERE url = '/sales'),
|
||||
'da-DK', 'Salg'),
|
||||
((SELECT id FROM {schema}.navigation_link_templates WHERE url = '/sales'),
|
||||
'en-US', 'Sales'),
|
||||
((SELECT id FROM {schema}.navigation_link_templates WHERE url = '/customers'),
|
||||
'da-DK', 'Kunder'),
|
||||
((SELECT id FROM {schema}.navigation_link_templates WHERE url = '/customers'),
|
||||
'en-US', 'Customers')";
|
||||
await _db.ExecuteAsync(insertTopTranslations);
|
||||
|
||||
// Translations for sub-navigation
|
||||
var insertSubTranslations = $@"
|
||||
INSERT INTO {schema}.navigation_link_template_translations
|
||||
(template_id, language, display_name)
|
||||
VALUES
|
||||
((SELECT id FROM {schema}.navigation_link_templates WHERE url = '/sales/create'),
|
||||
'da-DK', 'Opret salg'),
|
||||
((SELECT id FROM {schema}.navigation_link_templates WHERE url = '/sales/create'),
|
||||
'en-US', 'Create sale'),
|
||||
((SELECT id FROM {schema}.navigation_link_templates WHERE url = '/customers/create'),
|
||||
'da-DK', 'Opret kunde'),
|
||||
((SELECT id FROM {schema}.navigation_link_templates WHERE url = '/customers/create'),
|
||||
'en-US', 'Create customer')";
|
||||
await _db.ExecuteAsync(insertSubTranslations);
|
||||
|
||||
// Giv admin alle permissions
|
||||
var insertAdminPermissions = $@"
|
||||
INSERT INTO {schema}.role_permissions (role_id, permission_id)
|
||||
SELECT
|
||||
(SELECT id FROM {schema}.roles WHERE name = 'SYSTEM_ADMIN'),
|
||||
id
|
||||
FROM {schema}.permissions";
|
||||
await _db.ExecuteAsync(insertAdminPermissions);
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
344
NAMING_CONVENTION.md
Normal file
344
NAMING_CONVENTION.md
Normal file
|
|
@ -0,0 +1,344 @@
|
|||
# SWP.Core Naming Convention
|
||||
|
||||
Dette dokument definerer de officielle naming conventions for SWP.Core projektet baseret på analyse af eksisterende kodebase og .NET bedste praksis.
|
||||
|
||||
## Generelle Principper
|
||||
|
||||
1. **Konsistens**: Følg samme mønster gennem hele kodebasen
|
||||
2. **Læsbarhed**: Navne skal være selvforklarende og beskrivende
|
||||
3. **Sprogovervejelser**: Brug engelsk for al kode og kommentarer
|
||||
4. **Undgå forkortelser**: Brug fulde ord frem for forkortelser
|
||||
|
||||
## 1. Namespace Naming
|
||||
|
||||
### Standard Format
|
||||
```csharp
|
||||
SWP.Core.[FeatureArea].[SubArea]
|
||||
```
|
||||
|
||||
### Regler
|
||||
- **PascalCase** for alle segmenter
|
||||
- Brug `SWP.Core` som rod prefix (ikke `PlanTempus.Core`)
|
||||
- Hierarkisk struktur afspejler folder struktur
|
||||
- Maksimalt 4 niveauer dybt for læsbarhed
|
||||
|
||||
### Eksempler
|
||||
```csharp
|
||||
// ✅ Korrekt
|
||||
SWP.Core.CommandQueries
|
||||
SWP.Core.Configurations.SmartConfigProvider
|
||||
SWP.Core.Database.ConnectionFactory
|
||||
SWP.Core.Entities.Users
|
||||
SWP.Core.X.TDD.Security
|
||||
|
||||
// ❌ Forkert
|
||||
PlanTempus.Core.CommandQueries // Forkert prefix
|
||||
SWP.core.commandqueries // Forkert casing
|
||||
SWP.Core.Cfg.SmartCfgProv // Forkortelser
|
||||
```
|
||||
|
||||
## 2. Class Naming
|
||||
|
||||
### Regler
|
||||
- **PascalCase** for alle klasser
|
||||
- Beskrivende navne der afspejler klassens ansvar
|
||||
- Suffixes for specielle typer
|
||||
|
||||
### Class Type Suffixes
|
||||
| Type | Suffix | Eksempel |
|
||||
|------|--------|----------|
|
||||
| Abstract classes | (ingen) | `Command` |
|
||||
| Interfaces | `I` prefix | `ISecureTokenizer` |
|
||||
| Exceptions | `Exception` | `ConfigurationException` |
|
||||
| Factories | `Factory` | `PostgresConnectionFactory` |
|
||||
| Services | `Service` | `UserService` |
|
||||
| Extensions | `Extensions` | `TelemetryExtensions` |
|
||||
| Modules (Autofac) | `Module` | `SecurityModule` |
|
||||
| Configuration | `Configuration` eller `Options` | `SeqConfiguration`, `SmartConfigOptions` |
|
||||
| Tests | `Tests` | `SecureTokenizerTests` |
|
||||
|
||||
### Eksempler
|
||||
```csharp
|
||||
// ✅ Korrekt
|
||||
public class SecureTokenizer : ISecureTokenizer
|
||||
public class PostgresConnectionFactory : IDbConnectionFactory
|
||||
public abstract class Command : ICommand
|
||||
public class ConfigurationException : Exception
|
||||
|
||||
// ❌ Forkert
|
||||
public class secureTokenizer // Forkert casing
|
||||
public class Factory // For generisk
|
||||
public class SecureTokenizerImpl // Undgå "Impl" suffix
|
||||
```
|
||||
|
||||
## 3. Interface Naming
|
||||
|
||||
### Regler
|
||||
- **PascalCase** med `I` prefix
|
||||
- Beskriver capability eller kontrakt
|
||||
- Undgå `Interface` suffix
|
||||
|
||||
### Eksempler
|
||||
```csharp
|
||||
// ✅ Korrekt
|
||||
public interface ISecureTokenizer
|
||||
public interface IDbConnectionFactory
|
||||
public interface IConfigurationRepository
|
||||
|
||||
// ❌ Forkert
|
||||
public interface SecureTokenizer // Mangler I prefix
|
||||
public interface ISecureTokenizerInterface // Redundant suffix
|
||||
```
|
||||
|
||||
## 4. Method Naming
|
||||
|
||||
### Regler
|
||||
- **PascalCase** for alle metoder
|
||||
- Begynd med verbum der beskriver handlingen
|
||||
- Async metoder skal have `Async` suffix
|
||||
|
||||
### Naming Patterns
|
||||
| Pattern | Eksempel |
|
||||
|---------|----------|
|
||||
| Action methods | `TokenizeText()`, `VerifyToken()` |
|
||||
| Factory methods | `Create()`, `Build()` |
|
||||
| Async methods | `LogAsync()`, `CreateAsync()` |
|
||||
| Boolean methods | `IsValid()`, `CanExecute()`, `HasPermission()` |
|
||||
|
||||
### Eksempler
|
||||
```csharp
|
||||
// ✅ Korrekt
|
||||
public string TokenizeText(string word)
|
||||
public async Task<bool> VerifyTokenAsync(string hash, string word)
|
||||
public bool IsValidFormat(string input)
|
||||
|
||||
// ❌ Forkert
|
||||
public string tokenize_text(string word) // Forkert casing
|
||||
public async Task<bool> VerifyToken(string hash, string word) // Mangler Async suffix
|
||||
public bool ValidFormat(string input) // Mangler verbum
|
||||
```
|
||||
|
||||
## 5. Property Naming
|
||||
|
||||
### Regler
|
||||
- **PascalCase** for alle properties
|
||||
- Brug substantiver eller substantiv-sætninger
|
||||
- Auto-properties anbefales
|
||||
|
||||
### Eksempler
|
||||
```csharp
|
||||
// ✅ Korrekt
|
||||
public Guid CorrelationId { get; set; }
|
||||
public required string Email { get; set; }
|
||||
public DateTime CreatedDate { get; set; }
|
||||
public bool IsActive { get; set; }
|
||||
|
||||
// ❌ Forkert
|
||||
public Guid correlationId { get; set; } // Forkert casing
|
||||
public string GetEmail() { get; set; } // Metode navngivning
|
||||
```
|
||||
|
||||
## 6. Field Naming
|
||||
|
||||
### Private Fields
|
||||
- **camelCase** med underscore prefix `_`
|
||||
- Beskrivende navne
|
||||
|
||||
### Constants
|
||||
- **PascalCase** for public constants
|
||||
- **camelCase** med underscore prefix for private constants
|
||||
|
||||
### Readonly Fields
|
||||
- Som private fields med underscore prefix
|
||||
|
||||
### Eksempler
|
||||
```csharp
|
||||
// ✅ Korrekt
|
||||
private readonly IHttpClient _httpClient;
|
||||
private const int _saltSize = 16;
|
||||
public const string DefaultConnectionString = "...";
|
||||
private static readonly string _defaultEncoding = "UTF-8";
|
||||
|
||||
// ❌ Forkert
|
||||
private readonly IHttpClient httpClient; // Mangler underscore
|
||||
private const int SALT_SIZE = 16; // Forkert casing for private
|
||||
public const string default_connection = "..."; // Forkert casing for public
|
||||
```
|
||||
|
||||
## 7. Parameter Naming
|
||||
|
||||
### Regler
|
||||
- **camelCase** for alle parametre
|
||||
- Beskrivende navne der afspejler parameterens formål
|
||||
- Undgå single-letter navne (undtagen type parametre)
|
||||
|
||||
### Eksempler
|
||||
```csharp
|
||||
// ✅ Korrekt
|
||||
public bool VerifyToken(string hashedToken, string plainTextPassword)
|
||||
public void Configure(IServiceCollection services, string connectionString)
|
||||
public async Task<T> ExecuteAsync<T>(CancellationToken cancellationToken)
|
||||
|
||||
// ❌ Forkert
|
||||
public bool VerifyToken(string s1, string s2) // Ikke beskrivende
|
||||
public void Configure(string ConnString) // Forkert casing
|
||||
public async Task<T> ExecuteAsync<T>(CancellationToken ct) // Forkortelse
|
||||
```
|
||||
|
||||
## 8. File og Folder Naming
|
||||
|
||||
### Files
|
||||
- **PascalCase** der matcher hovedklassen i filen
|
||||
- En hovedklasse per fil (undtagen interne/helper klasser)
|
||||
|
||||
### Folders
|
||||
- **PascalCase** der afspejler namespace struktur
|
||||
- Brug plural for samlinger af entities (f.eks. `Users`, `Configurations`)
|
||||
|
||||
### Eksempler
|
||||
```
|
||||
✅ Korrekt
|
||||
/Core/CommandQueries/Command.cs
|
||||
/Core/Entities/Users/User.cs
|
||||
/Core/Configurations/SmartConfigProvider/SmartConfigProvider.cs
|
||||
/Tests/Security/SecureTokenizerTests.cs
|
||||
|
||||
❌ Forkert
|
||||
/Core/command-queries/command.cs
|
||||
/Core/entities/users/user.cs
|
||||
/Tests/security/secure_tokenizer_tests.cs
|
||||
```
|
||||
|
||||
## 9. Test Naming
|
||||
|
||||
### Test Classes
|
||||
- Klassenavn + `Tests` suffix
|
||||
- Samme namespace som den testede klasse + `.X.TDD`
|
||||
|
||||
### Test Methods
|
||||
- Format: `[MethodName]_Should[ExpectedBehavior]_[Condition]`
|
||||
- Eller: `[MethodName]_[Condition]_[ExpectedBehavior]`
|
||||
|
||||
### Test Projects
|
||||
- Format: `[ProjectName].X.TDD`
|
||||
|
||||
### Eksempler
|
||||
```csharp
|
||||
// ✅ Korrekt
|
||||
namespace SWP.Core.X.TDD.Security;
|
||||
|
||||
[TestClass]
|
||||
public class SecureTokenizerTests
|
||||
{
|
||||
[TestMethod]
|
||||
public void TokenizeText_ShouldReturnDifferentTokens_ForSamePassword()
|
||||
|
||||
[TestMethod]
|
||||
public void VerifyToken_WithValidPassword_ShouldReturnTrue()
|
||||
|
||||
[TestMethod]
|
||||
public void VerifyToken_WithNullInput_ShouldThrowException()
|
||||
}
|
||||
|
||||
// ❌ Forkert
|
||||
public class TestSecureTokenizer // Forkert prefix
|
||||
public void TokenizeTextTest() // Ikke beskrivende
|
||||
public void Test1() // Ikke beskrivende
|
||||
```
|
||||
|
||||
## 10. Configuration og Database Naming
|
||||
|
||||
### Configuration Keys
|
||||
- **PascalCase** for hver sektion
|
||||
- Hierarkisk med kolon separator
|
||||
- Logisk gruppering
|
||||
|
||||
### Database vs C# Mapping
|
||||
- **Database**: snake_case for kolonner
|
||||
- **C#**: PascalCase for properties
|
||||
- Brug mapping hvor nødvendigt
|
||||
|
||||
### Eksempler
|
||||
```json
|
||||
// ✅ Korrekt configuration
|
||||
{
|
||||
"Database": {
|
||||
"ConnectionString": "...",
|
||||
"CommandTimeout": 30
|
||||
},
|
||||
"Logging": {
|
||||
"Level": "Information",
|
||||
"Providers": {
|
||||
"Seq": {
|
||||
"Url": "http://localhost:5341"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
```sql
|
||||
-- ✅ Database (snake_case)
|
||||
CREATE TABLE users (
|
||||
id INT PRIMARY KEY,
|
||||
email VARCHAR(255),
|
||||
password_hash VARCHAR(255),
|
||||
created_at TIMESTAMP
|
||||
);
|
||||
```
|
||||
|
||||
```csharp
|
||||
// ✅ C# Entity (PascalCase)
|
||||
public class User
|
||||
{
|
||||
public int Id { get; set; }
|
||||
public string Email { get; set; }
|
||||
public string PasswordHash { get; set; }
|
||||
public DateTime CreatedAt { get; set; }
|
||||
}
|
||||
```
|
||||
|
||||
## 11. Specielle Konventioner
|
||||
|
||||
### Generic Type Parameters
|
||||
- Single letters: `T`, `TKey`, `TValue`
|
||||
- Beskrivende hvis nødvendigt: `TEntity`, `TRequest`, `TResponse`
|
||||
|
||||
### Event Handlers
|
||||
- Format: `On[EventName]` eller `Handle[EventName]`
|
||||
|
||||
### Extension Methods
|
||||
- Første parameter skal være `this`
|
||||
- Metoder i statiske klasser med `Extensions` suffix
|
||||
|
||||
### Async/Await
|
||||
- Altid `Async` suffix for async metoder
|
||||
- Brug `CancellationToken cancellationToken` som sidste parameter
|
||||
|
||||
## 12. Undtagelser fra Konventioner
|
||||
|
||||
### Acceptable Forkortelser
|
||||
- `Id` (frem for Identifier)
|
||||
- `Url` (frem for UniformResourceLocator)
|
||||
- `Http` (frem for HyperTextTransferProtocol)
|
||||
- `Json` (frem for JavaScriptObjectNotation)
|
||||
- `Sql` (frem for StructuredQueryLanguage)
|
||||
|
||||
### Kendte Patterns
|
||||
- `DTO` suffix for Data Transfer Objects
|
||||
- `CRUD` i kommentarer og dokumentation
|
||||
- `API` for Application Programming Interface
|
||||
|
||||
## Implementering
|
||||
|
||||
1. **Gradvis migration**: Ret eksisterende kode gradvist ved ændringer
|
||||
2. **Code reviews**: Håndhæv conventions i code reviews
|
||||
3. **Linting**: Konfigurer analyzers til at håndhæve konventioner
|
||||
4. **Dokumentation**: Hold denne guide opdateret
|
||||
|
||||
## Værktøjer
|
||||
|
||||
- **EditorConfig**: Konfigurer formatting regler
|
||||
- **StyleCop**: Håndhæv naming conventions
|
||||
- **SonarQube**: Code quality og consistency checks
|
||||
- **Visual Studio**: Code analysis og suggestions
|
||||
31
SWP.Core.sln
Normal file
31
SWP.Core.sln
Normal file
|
|
@ -0,0 +1,31 @@
|
|||
|
||||
Microsoft Visual Studio Solution File, Format Version 12.00
|
||||
# Visual Studio Version 17
|
||||
VisualStudioVersion = 17.14.36202.13 d17.14
|
||||
MinimumVisualStudioVersion = 10.0.40219.1
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "SWP.Core", "Core\SWP.Core.csproj", "{A89DB90D-5720-8689-A6DD-D077E7D85138}"
|
||||
EndProject
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "SWP.Core.X.TDD", "Tests\SWP.Core.X.TDD.csproj", "{76C3FC63-6C63-9B0B-698B-7AB1F08CB2AD}"
|
||||
EndProject
|
||||
Global
|
||||
GlobalSection(SolutionConfigurationPlatforms) = preSolution
|
||||
Debug|Any CPU = Debug|Any CPU
|
||||
Release|Any CPU = Release|Any CPU
|
||||
EndGlobalSection
|
||||
GlobalSection(ProjectConfigurationPlatforms) = postSolution
|
||||
{A89DB90D-5720-8689-A6DD-D077E7D85138}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||
{A89DB90D-5720-8689-A6DD-D077E7D85138}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||
{A89DB90D-5720-8689-A6DD-D077E7D85138}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||
{A89DB90D-5720-8689-A6DD-D077E7D85138}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||
{76C3FC63-6C63-9B0B-698B-7AB1F08CB2AD}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||
{76C3FC63-6C63-9B0B-698B-7AB1F08CB2AD}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||
{76C3FC63-6C63-9B0B-698B-7AB1F08CB2AD}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||
{76C3FC63-6C63-9B0B-698B-7AB1F08CB2AD}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||
EndGlobalSection
|
||||
GlobalSection(SolutionProperties) = preSolution
|
||||
HideSolutionNode = FALSE
|
||||
EndGlobalSection
|
||||
GlobalSection(ExtensibilityGlobals) = postSolution
|
||||
SolutionGuid = {D194DADC-70D5-41AA-86EC-666A1BE4794D}
|
||||
EndGlobalSection
|
||||
EndGlobal
|
||||
341
TECHNICAL_DOCUMENTATION.md
Normal file
341
TECHNICAL_DOCUMENTATION.md
Normal file
|
|
@ -0,0 +1,341 @@
|
|||
# SWP.Core - Technical Documentation
|
||||
|
||||
## Overview
|
||||
|
||||
SWP.Core is a modular .NET 9.0 enterprise application framework designed for multi-tenant SaaS applications. The system provides a comprehensive foundation for building scalable applications with advanced configuration management, security, telemetry, and database operations.
|
||||
|
||||
## Architecture
|
||||
|
||||
### High-Level Architecture
|
||||
|
||||
The system follows a layered architecture with clear separation of concerns:
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────┐
|
||||
│ Application Layer │
|
||||
├─────────────────────────────────────────┤
|
||||
│ Core Library │
|
||||
│ ┌─────────────┬─────────────────────┐ │
|
||||
│ │ Command/ │ Configuration │ │
|
||||
│ │ Query │ Management │ │
|
||||
│ ├─────────────┼─────────────────────┤ │
|
||||
│ │ Security & │ Telemetry & │ │
|
||||
│ │ Encryption │ Logging │ │
|
||||
│ ├─────────────┼─────────────────────┤ │
|
||||
│ │ Database │ Module Registry │ │
|
||||
│ │ Operations │ (Autofac) │ │
|
||||
│ └─────────────┴─────────────────────┘ │
|
||||
├─────────────────────────────────────────┤
|
||||
│ Database Layer │
|
||||
│ ┌─────────────────────────────────────┐ │
|
||||
│ │ PostgreSQL with Row Level Security │ │
|
||||
│ │ Multi-tenant Schema Management │ │
|
||||
│ └─────────────────────────────────────┘ │
|
||||
└─────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
### Technology Stack
|
||||
|
||||
- **Framework**: .NET 9.0
|
||||
- **Database**: PostgreSQL with Insight.Database
|
||||
- **DI Container**: Autofac
|
||||
- **Testing**: MSTest + Shouldly
|
||||
- **Telemetry**: Application Insights + Seq
|
||||
- **Security**: Sodium.Core for encryption
|
||||
- **Configuration**: Custom multi-provider system
|
||||
|
||||
## Core Components
|
||||
|
||||
### 1. Configuration Management System
|
||||
|
||||
**Location**: `Core/Configurations/`
|
||||
|
||||
The system implements a sophisticated multi-provider configuration system that supports:
|
||||
|
||||
- **JSON Configuration Provider**: File-based configuration
|
||||
- **Smart Configuration Provider**: Database-backed configuration with caching
|
||||
- **Hierarchical Configuration**: Nested configuration with path-based access
|
||||
|
||||
**Key Classes**:
|
||||
- [`ConfigurationBuilder`](Core/Configurations/ConfigurationBuilder.cs): Main builder for configuration providers
|
||||
- [`SmartConfigProvider`](Core/Configurations/SmartConfigProvider/SmartConfigProvider.cs): Database-backed configuration
|
||||
- [`PostgresConfigurationRepository`](Core/Configurations/SmartConfigProvider/Repositories/PostgresConfigurationRepository.cs): PostgreSQL storage
|
||||
|
||||
**Usage Example**:
|
||||
```csharp
|
||||
var config = new ConfigurationBuilder()
|
||||
.AddProvider(new JsonConfigProvider("appsettings.json"))
|
||||
.AddProvider(new SmartConfigProvider(connectionString))
|
||||
.Build();
|
||||
|
||||
var connectionString = config.GetConnectionString("DefaultConnection");
|
||||
var feature = config.Get<FeatureConfig>("Feature");
|
||||
```
|
||||
|
||||
### 2. Database Operations
|
||||
|
||||
**Location**: `Core/Database/`
|
||||
|
||||
Provides a robust database abstraction layer with:
|
||||
|
||||
- **Connection Factory Pattern**: [`IDbConnectionFactory`](Core/Database/ConnectionFactory/IDbConnectionFactory.cs)
|
||||
- **Operation Scoping**: [`DatabaseScope`](Core/Database/DatabaseScope.cs) for transaction and telemetry management
|
||||
- **Telemetry Integration**: Automatic performance tracking
|
||||
|
||||
**Key Classes**:
|
||||
- [`SqlOperations`](Core/Database/SqlOperations.cs): Main database operations class
|
||||
- [`PostgresConnectionFactory`](Core/Database/ConnectionFactory/PostgresConnectionFactory.cs): PostgreSQL connection management
|
||||
|
||||
**Usage Example**:
|
||||
```csharp
|
||||
var result = await _sqlOperations.ExecuteAsync(async conn =>
|
||||
{
|
||||
return await conn.QueryAsync<User>("SELECT * FROM users WHERE id = @id", new { id });
|
||||
}, "GetUserById");
|
||||
```
|
||||
|
||||
### 3. Security & Encryption
|
||||
|
||||
**Location**: `Core/MultiKeyEncryption/`, `Core/ISecureTokenizer.cs`
|
||||
|
||||
Implements enterprise-grade security features:
|
||||
|
||||
- **Multi-Key Encryption**: [`MasterKey`](Core/MultiKeyEncryption/MasterKey.cs) for key management
|
||||
- **Secure Connection Strings**: [`SecureConnectionString`](Core/MultiKeyEncryption/SecureConnectionString.cs)
|
||||
- **Token Security**: [`SecureTokenizer`](Core/SecureTokenizer.cs) using Sodium.Core
|
||||
|
||||
**Features**:
|
||||
- SHA-256 based token generation
|
||||
- Secure password hashing
|
||||
- Connection string encryption
|
||||
- Key rotation support
|
||||
|
||||
### 4. Command/Query Pattern
|
||||
|
||||
**Location**: `Core/CommandQueries/`
|
||||
|
||||
Implements a lightweight command/query pattern without MediatR:
|
||||
|
||||
- **Base Command**: [`Command`](Core/CommandQueries/Command.cs) with correlation tracking
|
||||
- **Command Interface**: [`ICommand`](Core/CommandQueries/ICommand.cs)
|
||||
- **Response Handling**: [`CommandResponse`](Core/CommandQueries/CommandResponse.cs)
|
||||
- **Problem Details**: [`ProblemDetails`](Core/CommandQueries/ProblemDetails.cs) for error handling
|
||||
|
||||
### 5. Telemetry & Logging
|
||||
|
||||
**Location**: `Core/Telemetry/`, `Core/SeqLogging/`
|
||||
|
||||
Comprehensive observability solution:
|
||||
|
||||
- **Application Insights Integration**: [`TelemetryExtensions`](Core/Telemetry/TelemetryExtensions.cs)
|
||||
- **Seq Logging**: [`SeqLogger`](Core/SeqLogging/SeqLogger.cs) for structured logging
|
||||
- **Custom Telemetry Channel**: [`SeqTelemetryChannel`](Core/Telemetry/SeqTelemetryChannel.cs)
|
||||
- **Background Processing**: [`SeqBackgroundService`](Core/SeqLogging/SeqBackgroundService.cs)
|
||||
|
||||
**Features**:
|
||||
- Structured logging with correlation IDs
|
||||
- Performance metrics collection
|
||||
- Exception tracking with full stack traces
|
||||
- Custom enrichers for metadata
|
||||
|
||||
### 6. Module Registry (Dependency Injection)
|
||||
|
||||
**Location**: `Core/ModuleRegistry/`
|
||||
|
||||
Autofac-based modular dependency injection:
|
||||
|
||||
- **Security Module**: [`SecurityModule`](Core/ModuleRegistry/SecurityModule.cs)
|
||||
- **Telemetry Module**: [`TelemetryModule`](Core/ModuleRegistry/TelemetryModule.cs)
|
||||
- **Seq Logging Module**: [`SeqLoggingModule`](Core/ModuleRegistry/SeqLoggingModule.cs)
|
||||
- **Database Module**: [`DbPostgreSqlModule`](Core/Database/ModuleRegistry/DbPostgreSqlModule.cs)
|
||||
|
||||
## Database Schema
|
||||
|
||||
### Multi-Tenant Architecture
|
||||
|
||||
The system implements a sophisticated multi-tenant architecture using PostgreSQL:
|
||||
|
||||
**Core Tables** (in identity schema):
|
||||
- **users**: User authentication and profile data
|
||||
- **organizations**: Tenant/organization management
|
||||
- **user_organizations**: Many-to-many relationship with PIN codes
|
||||
|
||||
**Security Features**:
|
||||
- **Row Level Security (RLS)**: Automatic tenant isolation
|
||||
- **Schema-based Separation**: Each tenant can have dedicated schemas
|
||||
- **Connection String Encryption**: Secure tenant database connections
|
||||
|
||||
**Example Schema Setup**:
|
||||
```sql
|
||||
-- From SetupIdentitySystem.cs
|
||||
CREATE TABLE identity.users (
|
||||
id SERIAL PRIMARY KEY,
|
||||
email VARCHAR(256) NOT NULL UNIQUE,
|
||||
password_hash VARCHAR(256) NOT NULL,
|
||||
security_stamp VARCHAR(36) NOT NULL,
|
||||
email_confirmed BOOLEAN NOT NULL DEFAULT FALSE,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
-- RLS Policy
|
||||
CREATE POLICY organization_access ON identity.organizations
|
||||
USING (id IN (
|
||||
SELECT organization_id
|
||||
FROM identity.user_organizations
|
||||
WHERE user_id = current_setting('app.user_id', TRUE)::INTEGER
|
||||
));
|
||||
```
|
||||
|
||||
## Testing Strategy
|
||||
|
||||
**Location**: `Tests/`
|
||||
|
||||
Comprehensive testing approach using MSTest + Shouldly:
|
||||
|
||||
- **Unit Tests**: Component-level testing
|
||||
- **Integration Tests**: Database and external service testing
|
||||
- **Configuration Tests**: Multi-provider configuration validation
|
||||
|
||||
**Testing Principles**:
|
||||
- Clear variable names for debugging
|
||||
- Shouldly assertions for readable test failures
|
||||
- Isolated test environments
|
||||
- Mock-based testing for external dependencies
|
||||
|
||||
**Example Test Structure**:
|
||||
```csharp
|
||||
[TestMethod]
|
||||
public void SecureTokenizer_Should_Generate_Valid_Token()
|
||||
{
|
||||
// Arrange
|
||||
var tokenizer = new SecureTokenizer();
|
||||
var inputText = "test-password";
|
||||
|
||||
// Act
|
||||
var generatedToken = tokenizer.TokenizeText(inputText);
|
||||
|
||||
// Assert
|
||||
generatedToken.ShouldNotBeNullOrEmpty();
|
||||
generatedToken.Length.ShouldBe(64); // SHA-256 hex length
|
||||
}
|
||||
```
|
||||
|
||||
## Configuration
|
||||
|
||||
### Multi-Provider Configuration System
|
||||
|
||||
The system supports multiple configuration sources with hierarchical merging:
|
||||
|
||||
1. **JSON Files**: Traditional appsettings.json
|
||||
2. **Database**: Dynamic configuration stored in PostgreSQL
|
||||
3. **Environment Variables**: Runtime configuration
|
||||
4. **Azure App Configuration**: Cloud-based configuration (planned)
|
||||
|
||||
**Configuration Structure**:
|
||||
```json
|
||||
{
|
||||
"ConnectionStrings": {
|
||||
"DefaultConnection": "Host=localhost;Database=app;..."
|
||||
},
|
||||
"ApplicationInsights": {
|
||||
"ConnectionString": "InstrumentationKey=...",
|
||||
"UseSeqLoggingTelemetryChannel": true
|
||||
},
|
||||
"SeqConfiguration": {
|
||||
"IngestionEndpoint": "http://localhost:5341",
|
||||
"Environment": "Development"
|
||||
},
|
||||
"Feature": {
|
||||
"Enabled": true,
|
||||
"RolloutPercentage": 25,
|
||||
"AllowedUserGroups": ["beta"]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Deployment & Operations
|
||||
|
||||
### Prerequisites
|
||||
|
||||
- .NET 9.0 Runtime
|
||||
- PostgreSQL 12+
|
||||
- Seq (for logging)
|
||||
- Application Insights (for telemetry)
|
||||
|
||||
### Environment Setup
|
||||
|
||||
1. **Database Setup**: Run DDL scripts from `Database/Core/DDL/`
|
||||
2. **Configuration**: Set up appsettings.json with connection strings
|
||||
3. **Logging**: Configure Seq endpoint
|
||||
4. **Telemetry**: Set Application Insights connection string
|
||||
|
||||
### Performance Considerations
|
||||
|
||||
- **Connection Pooling**: Managed by Npgsql
|
||||
- **Async Operations**: All database operations are async
|
||||
- **Telemetry Overhead**: Minimal impact with background processing
|
||||
- **Configuration Caching**: Smart config provider includes caching
|
||||
|
||||
## Security Considerations
|
||||
|
||||
### Authentication & Authorization
|
||||
|
||||
- **Password Security**: SHA-256 hashing with security stamps
|
||||
- **Token Management**: Secure token generation and validation
|
||||
- **Multi-Tenant Isolation**: RLS policies prevent cross-tenant data access
|
||||
|
||||
### Data Protection
|
||||
|
||||
- **Connection String Encryption**: Sensitive connection data encrypted
|
||||
- **Audit Trails**: Comprehensive logging of all operations
|
||||
- **Input Validation**: FluentValidation integration
|
||||
|
||||
### Compliance
|
||||
|
||||
- **GDPR Ready**: User data management and deletion capabilities
|
||||
- **Audit Logging**: Complete operation tracking
|
||||
- **Data Encryption**: At-rest and in-transit encryption
|
||||
|
||||
## Extension Points
|
||||
|
||||
### Adding New Modules
|
||||
|
||||
1. Create new Autofac module inheriting from `Module`
|
||||
2. Register services in `Load()` method
|
||||
3. Add module to container builder
|
||||
|
||||
### Custom Configuration Providers
|
||||
|
||||
1. Implement `IConfigurationProvider`
|
||||
2. Add to `ConfigurationBuilder`
|
||||
3. Handle configuration merging strategy
|
||||
|
||||
### Custom Telemetry
|
||||
|
||||
1. Extend `TelemetryExtensions`
|
||||
2. Add custom enrichers
|
||||
3. Configure Application Insights processors
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Common Issues
|
||||
|
||||
1. **Database Connection**: Check PostgreSQL connection strings and user permissions
|
||||
2. **Configuration Loading**: Verify JSON syntax and provider order
|
||||
3. **Telemetry**: Ensure Application Insights and Seq endpoints are accessible
|
||||
4. **Multi-Tenant**: Verify RLS policies and user context settings
|
||||
|
||||
### Debugging
|
||||
|
||||
- Enable detailed logging in Seq
|
||||
- Use Application Insights for performance monitoring
|
||||
- Check database query performance with PostgreSQL logs
|
||||
- Validate configuration loading with debug output
|
||||
|
||||
## Future Roadmap
|
||||
|
||||
- Azure App Configuration integration
|
||||
- Advanced caching strategies
|
||||
- GraphQL API support
|
||||
- Event sourcing capabilities
|
||||
- Microservices decomposition support
|
||||
42
Tests/.runsettings
Normal file
42
Tests/.runsettings
Normal file
|
|
@ -0,0 +1,42 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<RunSettings>
|
||||
<RunConfiguration>
|
||||
<MaxCpuCount>0</MaxCpuCount>
|
||||
<ResultsDirectory>.\TestResults</ResultsDirectory>
|
||||
<TargetFrameworkVersion>net9.0</TargetFrameworkVersion>
|
||||
</RunConfiguration>
|
||||
|
||||
<DataCollectionRunSettings>
|
||||
<DataCollectors>
|
||||
<DataCollector friendlyName="Code Coverage" uri="datacollector://Microsoft/CodeCoverage/2.0">
|
||||
<Configuration>
|
||||
<CodeCoverage>
|
||||
<ModulePaths>
|
||||
<Include>
|
||||
<ModulePath>.*SWP\.Core\.dll$</ModulePath>
|
||||
</Include>
|
||||
<Exclude>
|
||||
<ModulePath>.*Tests.*</ModulePath>
|
||||
</Exclude>
|
||||
</ModulePaths>
|
||||
<UseVerifiableInstrumentation>True</UseVerifiableInstrumentation>
|
||||
<AllowLowIntegrityProcesses>True</AllowLowIntegrityProcesses>
|
||||
<CollectFromChildProcesses>True</CollectFromChildProcesses>
|
||||
<CollectAspDotNet>False</CollectAspDotNet>
|
||||
</CodeCoverage>
|
||||
</Configuration>
|
||||
</DataCollector>
|
||||
</DataCollectors>
|
||||
</DataCollectionRunSettings>
|
||||
|
||||
<MSTest>
|
||||
<MapInconclusiveToFailed>false</MapInconclusiveToFailed>
|
||||
<CaptureTraceOutput>true</CaptureTraceOutput>
|
||||
<DeleteDeploymentDirectoryAfterTestRunIsComplete>true</DeleteDeploymentDirectoryAfterTestRunIsComplete>
|
||||
<DeploymentEnabled>true</DeploymentEnabled>
|
||||
<Parallelize>
|
||||
<Workers>0</Workers>
|
||||
<Scope>MethodLevel</Scope>
|
||||
</Parallelize>
|
||||
</MSTest>
|
||||
</RunSettings>
|
||||
40
Tests/CodeSnippets/TestPostgresLISTENNOTIFY.cs
Normal file
40
Tests/CodeSnippets/TestPostgresLISTENNOTIFY.cs
Normal file
|
|
@ -0,0 +1,40 @@
|
|||
using Npgsql;
|
||||
|
||||
namespace SWP.Core.X.TDD.CodeSnippets;
|
||||
|
||||
internal class TestPostgresLISTENNOTIFY
|
||||
{
|
||||
private static async Task Main(string[] args)
|
||||
{
|
||||
var connectionString = "Host=192.168.1.57;Database=ptdb01;Username=postgres;Password=3911";
|
||||
|
||||
try
|
||||
{
|
||||
await using var conn = new NpgsqlConnection(connectionString);
|
||||
await conn.OpenAsync();
|
||||
|
||||
Console.WriteLine("Forbundet til databasen. Lytter efter notifikationer...");
|
||||
|
||||
conn.Notification += (o, e) =>
|
||||
{
|
||||
Console.WriteLine("Notifikation modtaget:");
|
||||
Console.WriteLine($" PID: {e.PID}");
|
||||
Console.WriteLine($" Kanal: {e.Channel}");
|
||||
Console.WriteLine($" Payload: {e.Payload}");
|
||||
Console.WriteLine("------------------------");
|
||||
};
|
||||
|
||||
await using (var cmd = new NpgsqlCommand("LISTEN config_changes;", conn))
|
||||
await cmd.ExecuteNonQueryAsync();
|
||||
|
||||
Console.WriteLine("Tryk på en tast for at stoppe...");
|
||||
|
||||
while (!Console.KeyAvailable) await conn.WaitAsync();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Console.WriteLine($"Der opstod en fejl: {ex.Message}");
|
||||
Console.WriteLine($"Stack trace: {ex.StackTrace}");
|
||||
}
|
||||
}
|
||||
}
|
||||
11
Tests/CodeSnippets/sandbox.sql
Normal file
11
Tests/CodeSnippets/sandbox.sql
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
INSERT INTO "system".app_configuration ("key",value,"label",content_type,valid_from,expires_at,created_at,modified_at,etag) VALUES
|
||||
('Email:Templates:Welcome','{"subject":"Velkommen til vores platform","template":"welcome-dk.html","sender":"velkommen@firma.dk"}','test','application/json','2024-01-01 01:00:00+01',NULL,'2025-02-03 16:46:36.665888+01','2025-02-03 16:47:30.528326+01','c48949c4-c02f-4c77-b81c-e281a810def1'::uuid),
|
||||
('Email:Templates:Password','{"subject":"Nulstil dit kodeord","template":"reset-password-dk.html","sender":"support@firma.dk"}','Email Templates','application/json','2024-01-01 01:00:00+01',NULL,'2025-02-03 16:47:56.537775+01','2025-02-03 16:47:56.537775+01','26500738-4f5b-4cc8-a0e4-2a6a5fd57675'::uuid),
|
||||
('Debug','true',NULL,'text/plain',NULL,NULL,'2025-02-02 14:25:22.200058+01','2025-02-02 14:25:22.200058+01','f1348731-9396-4f1d-b40a-7fbd23a897d2'::uuid),
|
||||
('Database:ConnectionString','"Server=db.example.com;Port=5432"',NULL,'text/plain',NULL,NULL,'2025-02-02 14:25:22.200058+01','2025-02-02 14:25:22.200058+01','2aa0bc3e-fa24-449a-8f25-a76d9b4d535e'::uuid),
|
||||
('Database:Timeout','30',NULL,'text/plain',NULL,NULL,'2025-02-02 14:25:22.200058+01','2025-02-02 14:25:22.200058+01','d25ebb14-49f6-4e33-9ac7-a3253705d0fb'::uuid),
|
||||
('Database:UseSSL','true',NULL,'text/plain',NULL,NULL,'2025-02-02 14:25:22.200058+01','2025-02-02 14:25:22.200058+01','f4d52ec4-b723-4561-9b18-0e7a68b89a17'::uuid),
|
||||
('Logging:FileOptions','{"Path": "/var/logs/app.log", "MaxSizeMB": 100, "RetentionDays": 7}',NULL,'text/plain',NULL,NULL,'2025-02-02 14:25:22.200058+01','2025-02-02 14:25:22.200058+01','06c0891d-a860-4acc-917a-d0877f511c1b'::uuid),
|
||||
('Features:Experimental','{"Enabled": true, "RolloutPercentage": 25, "AllowedUserGroups": ["beta"]}',NULL,'text/plain',NULL,NULL,'2025-02-02 14:25:22.200058+01','2025-02-02 14:25:22.200058+01','0136fdef-51d9-4909-82ef-f72053ce6d6d'::uuid),
|
||||
('API:Endpoints','"/api/users"',NULL,'text/plain',NULL,NULL,'2025-02-02 14:25:22.200058+01','2025-02-02 14:25:22.200058+01','fe362b69-a486-48ad-9165-2e623e2e6f70'::uuid),
|
||||
('API:Endpoints','"/api/products"',NULL,'text/plain',NULL,NULL,'2025-02-02 14:25:22.200058+01','2025-02-02 14:25:22.200058+01','c087e2d4-1f38-4814-b4dd-f30c463dc6d1'::uuid);
|
||||
63
Tests/CommandQueries/CommandTests.cs
Normal file
63
Tests/CommandQueries/CommandTests.cs
Normal file
|
|
@ -0,0 +1,63 @@
|
|||
using Microsoft.VisualStudio.TestTools.UnitTesting;
|
||||
using Shouldly;
|
||||
using SWP.Core.CommandQueries;
|
||||
|
||||
namespace SWP.Core.X.TDD.CommandQueries;
|
||||
|
||||
[TestClass]
|
||||
public class CommandTests
|
||||
{
|
||||
[TestMethod]
|
||||
public void Command_ShouldHaveCorrelationId()
|
||||
{
|
||||
// Arrange & Act
|
||||
var correlationId = Guid.NewGuid();
|
||||
var command = new TestCommand { CorrelationId = correlationId };
|
||||
|
||||
// Assert
|
||||
command.CorrelationId.ShouldBe(correlationId);
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void Command_ShouldHaveTransactionId()
|
||||
{
|
||||
// Arrange & Act
|
||||
var correlationId = Guid.NewGuid();
|
||||
var transactionId = Guid.NewGuid();
|
||||
var command = new TestCommand { CorrelationId = correlationId };
|
||||
command.TransactionId = transactionId;
|
||||
|
||||
// Assert
|
||||
command.TransactionId.ShouldBe(transactionId);
|
||||
}
|
||||
|
||||
private class TestCommand : Command
|
||||
{
|
||||
public string TestProperty { get; set; }
|
||||
}
|
||||
}
|
||||
|
||||
[TestClass]
|
||||
public class ProblemDetailsTests
|
||||
{
|
||||
[TestMethod]
|
||||
public void ProblemDetails_ShouldHaveBasicProperties()
|
||||
{
|
||||
// Arrange & Act
|
||||
var problem = new ProblemDetails
|
||||
{
|
||||
Type = "ValidationError",
|
||||
Title = "Validation Failed",
|
||||
Status = 400,
|
||||
Detail = "Email is required",
|
||||
Instance = "/api/users"
|
||||
};
|
||||
|
||||
// Assert
|
||||
problem.Type.ShouldBe("ValidationError");
|
||||
problem.Title.ShouldBe("Validation Failed");
|
||||
problem.Status.ShouldBe(400);
|
||||
problem.Detail.ShouldBe("Email is required");
|
||||
problem.Instance.ShouldBe("/api/users");
|
||||
}
|
||||
}
|
||||
53
Tests/CommandQueryHandlerTests/ResponseTests.cs
Normal file
53
Tests/CommandQueryHandlerTests/ResponseTests.cs
Normal file
|
|
@ -0,0 +1,53 @@
|
|||
using Newtonsoft.Json;
|
||||
using Shouldly;
|
||||
using SWP.Core.CommandQueries;
|
||||
|
||||
namespace SWP.Core.X.TDD.CommandQueryHandlerTests;
|
||||
|
||||
[TestClass]
|
||||
public class ProblemDetailsTests
|
||||
{
|
||||
[TestMethod]
|
||||
public void TestFormatOfProblemDetails()
|
||||
{
|
||||
// Arrange
|
||||
var problemDetails = new ProblemDetails
|
||||
{
|
||||
Type = "https://example.com/errors/invalid-input",
|
||||
Title = "Invalid Input",
|
||||
Status = 400,
|
||||
Detail = "The request body is invalid.",
|
||||
Instance = "/api/users"
|
||||
};
|
||||
|
||||
problemDetails.AddExtension("invalidFields", new[]
|
||||
{
|
||||
new { Field = "name", Message = "The 'name' field is required." },
|
||||
new { Field = "email", Message = "The 'email' field must be a valid email address." }
|
||||
});
|
||||
|
||||
var json = JsonConvert.SerializeObject(problemDetails, Formatting.Indented);
|
||||
|
||||
var expectedJson = """
|
||||
{
|
||||
"Type": "https://example.com/errors/invalid-input",
|
||||
"Title": "Invalid Input",
|
||||
"Status": 400,
|
||||
"Detail": "The request body is invalid.",
|
||||
"Instance": "/api/users",
|
||||
"invalidFields": [
|
||||
{
|
||||
"Field": "name",
|
||||
"Message": "The 'name' field is required."
|
||||
},
|
||||
{
|
||||
"Field": "email",
|
||||
"Message": "The 'email' field must be a valid email address."
|
||||
}
|
||||
]
|
||||
}
|
||||
""";
|
||||
|
||||
json.ShouldBe(expectedJson);
|
||||
}
|
||||
}
|
||||
188
Tests/ConfigurationSystem/SetupConfigurationTests.cs
Normal file
188
Tests/ConfigurationSystem/SetupConfigurationTests.cs
Normal file
|
|
@ -0,0 +1,188 @@
|
|||
using System.Data;
|
||||
using Autofac;
|
||||
using Insight.Database;
|
||||
using Newtonsoft.Json;
|
||||
using SWP.Core.Database.ConnectionFactory;
|
||||
using Shouldly;
|
||||
|
||||
namespace SWP.Core.X.TDD.ConfigurationSystem;
|
||||
|
||||
[TestClass]
|
||||
public class SetupConfigurationTests : TestFixture
|
||||
{
|
||||
private IDbConnection _connection;
|
||||
|
||||
[TestInitialize]
|
||||
public void Setup()
|
||||
{
|
||||
var connectionFactory = Container.Resolve<IDbConnectionFactory>();
|
||||
_connection = connectionFactory.Create();
|
||||
}
|
||||
|
||||
[TestCleanup]
|
||||
public void Cleanup()
|
||||
{
|
||||
_connection.ExecuteSql(@"
|
||||
TRUNCATE TABLE app_configuration_history;
|
||||
TRUNCATE TABLE app_configuration CASCADE;");
|
||||
|
||||
_connection.Dispose();
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void InsertConfiguration_ShouldCreateHistoryRecord()
|
||||
{
|
||||
// Arrange
|
||||
var configData = new
|
||||
{
|
||||
key = "test.key",
|
||||
value = "test value",
|
||||
label = "Test Label"
|
||||
};
|
||||
|
||||
// Act
|
||||
var result = _connection.QuerySql<dynamic>(@"
|
||||
INSERT INTO app_configuration (key, value, label)
|
||||
VALUES (@key, @value, @label)
|
||||
RETURNING *", configData).Single();
|
||||
|
||||
var history = _connection.QuerySql<dynamic>(@"
|
||||
SELECT key, value, label, action_type
|
||||
FROM app_configuration_history
|
||||
WHERE id = @id AND action_type = 'I'",
|
||||
new { id = (int)result.id })
|
||||
.Single();
|
||||
|
||||
// Assert
|
||||
var expected = JsonConvert.SerializeObject(new
|
||||
{
|
||||
configData.key,
|
||||
configData.value,
|
||||
configData.label,
|
||||
action_type = "I"
|
||||
});
|
||||
var actual = JsonConvert.SerializeObject(history) as string;
|
||||
actual.ShouldBe(expected);
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void UpdateConfiguration_ShouldUpdateModifiedAt()
|
||||
{
|
||||
// Arrange
|
||||
var configData = new
|
||||
{
|
||||
key = "test.key",
|
||||
value = "original value"
|
||||
};
|
||||
|
||||
var original = _connection.QuerySql<dynamic>(@"
|
||||
INSERT INTO app_configuration (key, value)
|
||||
VALUES (@key, @value)
|
||||
RETURNING modified_at", configData)
|
||||
.Single();
|
||||
|
||||
Thread.Sleep(1000);
|
||||
|
||||
// Act
|
||||
var updated = _connection.QuerySql<dynamic>(@"
|
||||
UPDATE app_configuration
|
||||
SET value = @value
|
||||
WHERE key = @key
|
||||
RETURNING modified_at",
|
||||
new { configData.key, value = "updated value" })
|
||||
.Single();
|
||||
|
||||
// Assert
|
||||
((DateTime)updated.modified_at).ShouldBeGreaterThan((DateTime)original.modified_at);
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void DeleteConfiguration_ShouldCreateHistoryRecord()
|
||||
{
|
||||
// Arrange
|
||||
var configData = new
|
||||
{
|
||||
key = "test.key",
|
||||
value = "test value"
|
||||
};
|
||||
|
||||
var original = _connection.QuerySql<dynamic>(@"
|
||||
INSERT INTO app_configuration (key, value)
|
||||
VALUES (@key, @value)
|
||||
RETURNING id", configData)
|
||||
.Single();
|
||||
|
||||
// Act
|
||||
_connection.ExecuteSql(
|
||||
"DELETE FROM app_configuration WHERE id = @id",
|
||||
new { id = (int)original.id });
|
||||
|
||||
// Assert
|
||||
var history = _connection.QuerySql<dynamic>(@"
|
||||
SELECT key, value, action_type
|
||||
FROM app_configuration_history
|
||||
WHERE id = @id AND action_type = 'D'",
|
||||
new { id = (int)original.id })
|
||||
.Single();
|
||||
|
||||
var expected = JsonConvert.SerializeObject(new
|
||||
{
|
||||
configData.key,
|
||||
configData.value,
|
||||
action_type = "D"
|
||||
});
|
||||
var actual = JsonConvert.SerializeObject(history) as string;
|
||||
actual.ShouldBe(expected);
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void InsertConfiguration_ShouldSetAllColumns()
|
||||
{
|
||||
// Arrange
|
||||
var now = DateTime.UtcNow;
|
||||
now = new DateTime(now.Year, now.Month, now.Day, now.Hour, now.Minute, 0, DateTimeKind.Utc);
|
||||
var configData = new
|
||||
{
|
||||
key = "test.columns",
|
||||
value = "test value",
|
||||
label = "Test Label",
|
||||
content_type = "application/json",
|
||||
valid_from = now,
|
||||
expires_at = now.AddDays(30)
|
||||
};
|
||||
|
||||
// Act
|
||||
var result = _connection.QuerySql<dynamic>(@"
|
||||
INSERT INTO app_configuration (
|
||||
key,
|
||||
value,
|
||||
label,
|
||||
content_type,
|
||||
valid_from,
|
||||
expires_at)
|
||||
VALUES (
|
||||
@key,
|
||||
@value,
|
||||
@label,
|
||||
@content_type,
|
||||
@valid_from,
|
||||
@expires_at)
|
||||
RETURNING key, value, label, content_type,
|
||||
CAST(EXTRACT(EPOCH FROM date_trunc('minute', valid_from)) AS INTEGER) as valid_from,
|
||||
CAST(EXTRACT(EPOCH FROM date_trunc('minute', expires_at)) AS INTEGER) as expires_at", configData)
|
||||
.Single();
|
||||
|
||||
// Assert
|
||||
var expected = JsonConvert.SerializeObject(new
|
||||
{
|
||||
configData.key,
|
||||
configData.value,
|
||||
configData.label,
|
||||
configData.content_type,
|
||||
valid_from = ((DateTimeOffset)configData.valid_from).ToUnixTimeSeconds(),
|
||||
expires_at = ((DateTimeOffset)configData.expires_at).ToUnixTimeSeconds()
|
||||
});
|
||||
|
||||
Assert.AreEqual(expected, JsonConvert.SerializeObject(result));
|
||||
}
|
||||
}
|
||||
145
Tests/ConfigurationTests/JsonConfigurationProviderTests.cs
Normal file
145
Tests/ConfigurationTests/JsonConfigurationProviderTests.cs
Normal file
|
|
@ -0,0 +1,145 @@
|
|||
using Newtonsoft.Json.Linq;
|
||||
using SWP.Core.Configurations;
|
||||
using SWP.Core.Configurations.JsonConfigProvider;
|
||||
using SWP.Core.Configurations.SmartConfigProvider;
|
||||
using Shouldly;
|
||||
using SWP.Core.X.TDD;
|
||||
|
||||
namespace SWP.Core.X.TDD.ConfigurationTests;
|
||||
|
||||
[TestClass]
|
||||
public class JsonConfigurationProviderTests : TestFixture
|
||||
{
|
||||
private const string _testFolder = "ConfigurationTests/";
|
||||
|
||||
public JsonConfigurationProviderTests() : base(_testFolder)
|
||||
{
|
||||
}
|
||||
|
||||
|
||||
[TestMethod]
|
||||
public void GetSection_ShouldReturnCorrectFeatureSection()
|
||||
{
|
||||
// Arrange
|
||||
var expectedJObject = JObject.Parse(@"{
|
||||
'Enabled': true,
|
||||
'RolloutPercentage': 25,
|
||||
'AllowedUserGroups': ['beta']
|
||||
}") as JToken;
|
||||
|
||||
var builder = new ConfigurationBuilder()
|
||||
.AddJsonFile($"{_testFolder}appconfiguration.dev.json")
|
||||
.Build();
|
||||
|
||||
// Act
|
||||
var section = builder.GetSection("Feature");
|
||||
|
||||
// Assert
|
||||
section.ShouldNotBeNull();
|
||||
section.Value.ShouldBeEquivalentTo(expectedJObject);
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void Get_ShouldReturnCorrectFeatureObject()
|
||||
{
|
||||
// Arrange
|
||||
var expectedFeature = new Feature
|
||||
{
|
||||
Enabled = true,
|
||||
RolloutPercentage = 25,
|
||||
AllowedUserGroups = new List<string> { "beta" }
|
||||
};
|
||||
|
||||
var builder = new ConfigurationBuilder()
|
||||
.AddJsonFile($"{_testFolder}appconfiguration.dev.json")
|
||||
.Build();
|
||||
|
||||
// Act
|
||||
var actualFeature = builder.GetSection("Feature").ToObject<Feature>();
|
||||
#pragma warning disable CS0618 // Type or member is obsolete
|
||||
var actualFeatureObsoleted = builder.GetSection("Feature").Get<Feature>();
|
||||
#pragma warning restore CS0618 // Type or member is obsolete
|
||||
// Assert
|
||||
actualFeature.ShouldBeEquivalentTo(expectedFeature);
|
||||
actualFeatureObsoleted.ShouldBeEquivalentTo(expectedFeature);
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void Get_ShouldReturnCorrectValueAsString()
|
||||
{
|
||||
// Arrange
|
||||
var expectedFeature = "123";
|
||||
|
||||
var builder = new ConfigurationBuilder()
|
||||
.AddJsonFile($"{_testFolder}appconfiguration.dev.json")
|
||||
.Build();
|
||||
|
||||
// Act
|
||||
var actualFeature = builder.GetSection("AnotherSetting").Get<string>("Thresholds:High");
|
||||
|
||||
// Assert
|
||||
actualFeature.ShouldBeEquivalentTo(expectedFeature);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Testing a stupid indexer for compability with Microsoft ConfigurationBuilder
|
||||
/// </summary>
|
||||
[TestMethod]
|
||||
public void Indexer_ShouldReturnValueAsString()
|
||||
{
|
||||
// Arrange
|
||||
var expected = "SHA256";
|
||||
|
||||
var builder = new ConfigurationBuilder()
|
||||
.AddJsonFile($"{_testFolder}appconfiguration.dev.json")
|
||||
.Build();
|
||||
|
||||
// Act
|
||||
var actual = builder["Authentication"];
|
||||
|
||||
// Assert
|
||||
actual.ShouldBeEquivalentTo(expected);
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void Get_ShouldReturnCorrectValueAsInt()
|
||||
{
|
||||
// Arrange
|
||||
var expectedFeature = 22;
|
||||
|
||||
var builder = new ConfigurationBuilder()
|
||||
.AddJsonFile($"{_testFolder}appconfiguration.dev.json")
|
||||
.Build();
|
||||
|
||||
// Act
|
||||
var actualFeature = builder.GetSection("AnotherSetting:Temperature").Get<int>("Indoor:Max:Limit");
|
||||
|
||||
// Assert
|
||||
actualFeature.ShouldBe(expectedFeature);
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void Get_ShouldReturnCorrectValueAsBool()
|
||||
{
|
||||
// Arrange
|
||||
var expectedFeature = true;
|
||||
|
||||
var configRoot = new ConfigurationBuilder()
|
||||
.AddJsonFile($"{_testFolder}appconfiguration.dev.json")
|
||||
.AddSmartConfig()
|
||||
.Build();
|
||||
|
||||
// Act
|
||||
var actualFeature = configRoot.Get<bool>("Database:UseSSL");
|
||||
|
||||
// Assert
|
||||
actualFeature.ShouldBe(expectedFeature);
|
||||
}
|
||||
}
|
||||
|
||||
internal class Feature
|
||||
{
|
||||
public bool Enabled { get; set; }
|
||||
public int RolloutPercentage { get; set; }
|
||||
public List<string> AllowedUserGroups { get; set; }
|
||||
}
|
||||
75
Tests/ConfigurationTests/KeyValueJsonHandlingTests.cs
Normal file
75
Tests/ConfigurationTests/KeyValueJsonHandlingTests.cs
Normal file
|
|
@ -0,0 +1,75 @@
|
|||
using Newtonsoft.Json.Linq;
|
||||
using SWP.Core.Configurations.Common;
|
||||
|
||||
namespace SWP.Core.X.TDD.ConfigurationTests;
|
||||
|
||||
[TestClass]
|
||||
public class ConfigurationTests : TestFixture
|
||||
{
|
||||
[TestInitialize]
|
||||
public void Init()
|
||||
{
|
||||
}
|
||||
|
||||
|
||||
[TestMethod]
|
||||
public void ConfigurationSettingsTest()
|
||||
{
|
||||
var pairs = new List<KeyValuePair<string, JToken>>
|
||||
{
|
||||
new("Debug", true),
|
||||
// Database konfiguration
|
||||
new("Database:ConnectionString", "Server=db.example.com;Port=5432"),
|
||||
new("Database:Timeout", 30),
|
||||
new("Database:UseSSL", true),
|
||||
|
||||
// Logging konfiguration med JObject
|
||||
new("Logging:FileOptions", JObject.Parse(@"{
|
||||
'Path': '/var/logs/app.log',
|
||||
'MaxSizeMB': 100,
|
||||
'RetentionDays': 7
|
||||
}")),
|
||||
|
||||
// Feature flags med kompleks konfiguration
|
||||
new("Features:Experimental", JObject.Parse(@"{
|
||||
'Enabled': true,
|
||||
'RolloutPercentage': 25,
|
||||
'AllowedUserGroups': ['beta']
|
||||
}")),
|
||||
|
||||
// API endpoints med array
|
||||
new("API:Endpoints", "/api/users"),
|
||||
new("API:Endpoints", "/api/products")
|
||||
};
|
||||
|
||||
var result = KeyValueToJson.Convert(pairs);
|
||||
|
||||
var expected = JObject.Parse(@"{
|
||||
'Debug' : true,
|
||||
'Database': {
|
||||
'ConnectionString': 'Server=db.example.com;Port=5432',
|
||||
'Timeout': 30,
|
||||
'UseSSL': true
|
||||
},
|
||||
'Logging': {
|
||||
'FileOptions': {
|
||||
'Path': '/var/logs/app.log',
|
||||
'MaxSizeMB': 100,
|
||||
'RetentionDays': 7
|
||||
}
|
||||
},
|
||||
'Features': {
|
||||
'Experimental': {
|
||||
'Enabled': true,
|
||||
'RolloutPercentage': 25,
|
||||
'AllowedUserGroups': ['beta']
|
||||
}
|
||||
},
|
||||
'API': {
|
||||
'Endpoints': ['/api/users', '/api/products']
|
||||
}
|
||||
}");
|
||||
|
||||
Assert.IsTrue(JToken.DeepEquals(expected, result));
|
||||
}
|
||||
}
|
||||
82
Tests/ConfigurationTests/SmartConfigProviderTests.cs
Normal file
82
Tests/ConfigurationTests/SmartConfigProviderTests.cs
Normal file
|
|
@ -0,0 +1,82 @@
|
|||
using Autofac;
|
||||
using Insight.Database;
|
||||
using SWP.Core.Configurations;
|
||||
using SWP.Core.Configurations.JsonConfigProvider;
|
||||
using SWP.Core.Configurations.SmartConfigProvider;
|
||||
using SWP.Core.Database.ConnectionFactory;
|
||||
using Shouldly;
|
||||
|
||||
namespace SWP.Core.X.TDD.ConfigurationTests;
|
||||
|
||||
[TestClass]
|
||||
public class SmartConfigProviderTests : TestFixture
|
||||
{
|
||||
private const string _testFolder = "ConfigurationTests/";
|
||||
|
||||
[TestMethod]
|
||||
public void TrySmartConfigWithOptionsForPostgres()
|
||||
{
|
||||
var config = new ConfigurationBuilder()
|
||||
.AddJsonFile($"{_testFolder}appconfiguration.dev.json")
|
||||
.AddSmartConfig(options => options.UsePostgres("DefaultConnection"))
|
||||
.Build();
|
||||
|
||||
var actualFeature = config.Get<bool>("Database:UseSSL");
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void Get_ShouldReturnCorrectValueAsBool()
|
||||
{
|
||||
// Arrange
|
||||
var expectedFeature = true;
|
||||
|
||||
var config = new ConfigurationBuilder()
|
||||
.AddJsonFile($"{_testFolder}appconfiguration.dev.json")
|
||||
.AddSmartConfig(options => options.UsePostgres("DefaultConnection"))
|
||||
.Build();
|
||||
|
||||
// Act
|
||||
var actualFeature = config.Get<bool>("Database:UseSSL");
|
||||
|
||||
// Assert
|
||||
actualFeature.ShouldBe(expectedFeature);
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void Get_ShouldReturnCorrectValueWhenSelectingIntoValueRowInConfigTable()
|
||||
{
|
||||
// Arrange
|
||||
var expectedFeature = 100;
|
||||
|
||||
var builder = new ConfigurationBuilder()
|
||||
.AddJsonFile($"{_testFolder}appconfiguration.dev.json")
|
||||
.AddSmartConfig(options => options.UsePostgres("DefaultConnection"))
|
||||
.Build();
|
||||
|
||||
// Act
|
||||
var actualFeature = builder.GetSection("Logging:FileOptions").Get<int>("MaxSizeMB");
|
||||
var withoutSectionThisAlsoWorks = builder.Get<int>("Logging:FileOptions:MaxSizeMB");
|
||||
|
||||
// Assert
|
||||
actualFeature.ShouldBe(expectedFeature);
|
||||
actualFeature.ShouldBe(withoutSectionThisAlsoWorks);
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void TryGetActiveConfigurations()
|
||||
{
|
||||
var connFactory = Container.Resolve<IDbConnectionFactory>();
|
||||
|
||||
const string sql = @"
|
||||
SELECT id, ""key"", value, label, content_type,
|
||||
valid_from, expires_at, created_at, modified_at, etag
|
||||
FROM app_configuration
|
||||
WHERE CURRENT_TIMESTAMP BETWEEN valid_from AND expires_at
|
||||
OR (valid_from IS NULL AND expires_at IS NULL)";
|
||||
|
||||
using (var conn = connFactory.Create())
|
||||
{
|
||||
var result = conn.QuerySql(sql);
|
||||
}
|
||||
}
|
||||
}
|
||||
74
Tests/ConfigurationTests/appconfiguration.dev.json
Normal file
74
Tests/ConfigurationTests/appconfiguration.dev.json
Normal file
|
|
@ -0,0 +1,74 @@
|
|||
{
|
||||
"AllowedHosts": "*",
|
||||
"ConnectionStrings": {
|
||||
"DefaultConnection": "Host=192.168.1.57;Port=5432;Database=sandbox;User Id=sathumper;Password=3911;"
|
||||
},
|
||||
"ApplicationInsights": {
|
||||
"ConnectionString": "InstrumentationKey=07d2a2b9-5e8e-4924-836e-264f8438f6c5;IngestionEndpoint=https://northeurope-2.in.applicationinsights.azure.com/;LiveEndpoint=https://northeurope.livediagnostics.monitor.azure.com/;ApplicationId=56748c39-2fa3-4880-a1e2-24068e791548",
|
||||
"UseSeqLoggingTelemetryChannel": true
|
||||
},
|
||||
"SeqConfiguration": {
|
||||
"IngestionEndpoint": "http://localhost:5341",
|
||||
"ApiKey": null,
|
||||
"Environment": "MSTEST"
|
||||
},
|
||||
"Authentication": "SHA256",
|
||||
"Feature": {
|
||||
"Enabled": true,
|
||||
"RolloutPercentage": 25,
|
||||
"AllowedUserGroups": [
|
||||
"beta"
|
||||
]
|
||||
},
|
||||
"AnotherSetting": {
|
||||
"Thresholds": {
|
||||
"High": "123",
|
||||
"Low": "-1"
|
||||
},
|
||||
"Temperature": {
|
||||
"Indoor": {
|
||||
"Max": {
|
||||
"Limit": 22
|
||||
},
|
||||
"Min": {
|
||||
"Limit": 18
|
||||
}
|
||||
},
|
||||
"Outdoor": {
|
||||
"Max": {
|
||||
"Limit": 12
|
||||
},
|
||||
"Min": {
|
||||
"Limit": 9
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"Serilog": {
|
||||
"MinimumLevel": {
|
||||
"Default": "Information",
|
||||
"Override": {
|
||||
"Microsoft": "Warning",
|
||||
"System": "Warning"
|
||||
}
|
||||
},
|
||||
"WriteTo": [
|
||||
{
|
||||
"Name": "Seq",
|
||||
"Args": {
|
||||
"serverUrl": "http://localhost:5341",
|
||||
"apiKey": ""
|
||||
}
|
||||
}
|
||||
],
|
||||
"Enrich": [
|
||||
"WithMachineName",
|
||||
"WithThreadId",
|
||||
"WithProcessId",
|
||||
"WithEnvironmentName"
|
||||
],
|
||||
"Properties": {
|
||||
"Application": "PlanTempus"
|
||||
}
|
||||
}
|
||||
}
|
||||
83
Tests/Entities/UserTests.cs
Normal file
83
Tests/Entities/UserTests.cs
Normal file
|
|
@ -0,0 +1,83 @@
|
|||
using Microsoft.VisualStudio.TestTools.UnitTesting;
|
||||
using Shouldly;
|
||||
using SWP.Core.Entities.Users;
|
||||
using SWP.Core.X.TDD.TestHelpers;
|
||||
|
||||
namespace SWP.Core.X.TDD.Entities;
|
||||
|
||||
[TestClass]
|
||||
public class UserTests
|
||||
{
|
||||
[TestMethod]
|
||||
public void User_ShouldHaveBasicProperties()
|
||||
{
|
||||
// Arrange & Act
|
||||
var user = new User
|
||||
{
|
||||
Id = 1,
|
||||
Email = "test@example.com",
|
||||
PasswordHash = "hashedPassword",
|
||||
SecurityStamp = "securityStamp",
|
||||
EmailConfirmed = true,
|
||||
CreatedDate = DateTime.UtcNow
|
||||
};
|
||||
|
||||
// Assert
|
||||
user.Id.ShouldBe(1);
|
||||
user.Email.ShouldBe("test@example.com");
|
||||
user.PasswordHash.ShouldBe("hashedPassword");
|
||||
user.SecurityStamp.ShouldBe("securityStamp");
|
||||
user.EmailConfirmed.ShouldBeTrue();
|
||||
user.CreatedDate.ShouldBeInRange(DateTime.UtcNow.AddMinutes(-1), DateTime.UtcNow.AddMinutes(1));
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void TestDataBuilder_ShouldCreateValidUser()
|
||||
{
|
||||
// Act
|
||||
var user = TestDataBuilder.Users.CreateTestUser();
|
||||
|
||||
// Assert
|
||||
user.ShouldNotBeNull();
|
||||
user.Email.ShouldNotBeNullOrEmpty();
|
||||
user.Email.ShouldContain("@example.com");
|
||||
user.CreatedDate.ShouldBeInRange(DateTime.UtcNow.AddMinutes(-1), DateTime.UtcNow.AddMinutes(1));
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void TestDataBuilder_ShouldCreateUserWithCustomEmail()
|
||||
{
|
||||
// Arrange
|
||||
var customEmail = "custom@test.com";
|
||||
|
||||
// Act
|
||||
var user = TestDataBuilder.Users.CreateTestUser(customEmail);
|
||||
|
||||
// Assert
|
||||
user.Email.ShouldBe(customEmail);
|
||||
}
|
||||
}
|
||||
|
||||
[TestClass]
|
||||
public class OrganizationTests
|
||||
{
|
||||
[TestMethod]
|
||||
public void Organization_ShouldHaveBasicProperties()
|
||||
{
|
||||
// Arrange & Act
|
||||
var org = new Organization
|
||||
{
|
||||
Id = 1,
|
||||
ConnectionString = "test connection",
|
||||
CreatedDate = DateTime.UtcNow,
|
||||
CreatedBy = 1,
|
||||
IsActive = true
|
||||
};
|
||||
|
||||
// Assert
|
||||
org.Id.ShouldBe(1);
|
||||
org.ConnectionString.ShouldBe("test connection");
|
||||
org.CreatedBy.ShouldBe(1);
|
||||
org.IsActive.ShouldBeTrue();
|
||||
}
|
||||
}
|
||||
71
Tests/Logging/SeqBackgroundServiceTest.cs
Normal file
71
Tests/Logging/SeqBackgroundServiceTest.cs
Normal file
|
|
@ -0,0 +1,71 @@
|
|||
using System.Net;
|
||||
using Autofac;
|
||||
using Microsoft.ApplicationInsights;
|
||||
using Microsoft.ApplicationInsights.Channel;
|
||||
using Microsoft.ApplicationInsights.DataContracts;
|
||||
using SWP.Core.SeqLogging;
|
||||
using SWP.Core.Telemetry;
|
||||
|
||||
namespace SWP.Core.X.TDD.Logging;
|
||||
|
||||
[TestClass]
|
||||
public class SeqBackgroundServiceTest : TestFixture
|
||||
{
|
||||
private CancellationTokenSource _cts;
|
||||
private IMessageChannel<ITelemetry> _messageChannel;
|
||||
private SeqBackgroundService _service;
|
||||
|
||||
[TestInitialize]
|
||||
public void SetupThis()
|
||||
{
|
||||
_messageChannel = new MessageChannel();
|
||||
var telemetryClient = Container.Resolve<TelemetryClient>();
|
||||
|
||||
var config = new SeqConfiguration("http://localhost:5341", null, "MSTEST");
|
||||
|
||||
var httpClient = new SeqHttpClient(config);
|
||||
var logger = new SeqLogger<SeqBackgroundService>(httpClient, config);
|
||||
|
||||
_service = new SeqBackgroundService(telemetryClient, _messageChannel, logger);
|
||||
_cts = new CancellationTokenSource();
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public async Task Messages_ShouldBeProcessedFromQueue()
|
||||
{
|
||||
await _service.StartAsync(_cts.Token);
|
||||
|
||||
for (var i = 0; i < 5; i++)
|
||||
{
|
||||
var eventTelemetry = new EventTelemetry
|
||||
{
|
||||
Name = "Test Event",
|
||||
Timestamp = DateTimeOffset.UtcNow
|
||||
};
|
||||
eventTelemetry.Properties.Add("TestId", Guid.NewGuid().ToString());
|
||||
eventTelemetry.Metrics.Add("TestMetric", 42.0);
|
||||
|
||||
await _messageChannel.Writer.WriteAsync(eventTelemetry);
|
||||
}
|
||||
|
||||
// wait for processing
|
||||
await Task.Delay(5000);
|
||||
|
||||
_cts.Cancel(); //not sure about this, we need to analyse more before this is "the way"
|
||||
await _service.StopAsync(CancellationToken.None);
|
||||
|
||||
|
||||
var hasMoreMessages = await _messageChannel.Reader.WaitToReadAsync();
|
||||
Assert.IsFalse(hasMoreMessages, "Queue should be empty after 5 seconds");
|
||||
}
|
||||
|
||||
private class TestMessageHandler : HttpMessageHandler
|
||||
{
|
||||
protected override Task<HttpResponseMessage> SendAsync(
|
||||
HttpRequestMessage request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
return Task.FromResult(new HttpResponseMessage(HttpStatusCode.OK));
|
||||
}
|
||||
}
|
||||
}
|
||||
145
Tests/Logging/SeqLoggerTests.cs
Normal file
145
Tests/Logging/SeqLoggerTests.cs
Normal file
|
|
@ -0,0 +1,145 @@
|
|||
using Autofac;
|
||||
using Microsoft.ApplicationInsights;
|
||||
using Microsoft.ApplicationInsights.DataContracts;
|
||||
using SWP.Core.SeqLogging;
|
||||
|
||||
namespace SWP.Core.X.TDD.Logging;
|
||||
|
||||
[TestClass]
|
||||
public class SeqLoggerTests : TestFixture
|
||||
{
|
||||
private readonly string _testId;
|
||||
private readonly SeqHttpClient _httpClient;
|
||||
private readonly SeqLogger<SeqLoggerTests> _logger;
|
||||
|
||||
public SeqLoggerTests()
|
||||
{
|
||||
_testId = Guid.NewGuid().ToString();
|
||||
var config = new SeqConfiguration("http://localhost:5341", null, "MSTEST");
|
||||
_httpClient = new SeqHttpClient(config);
|
||||
_logger = new SeqLogger<SeqLoggerTests>(_httpClient, config);
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public async Task LogTraceTelemetry_SendsCorrectDataWithErrorLevel()
|
||||
{
|
||||
// Arrange
|
||||
var traceTelemetry = new TraceTelemetry
|
||||
{
|
||||
Message = "Test trace error message",
|
||||
SeverityLevel = SeverityLevel.Error,
|
||||
Timestamp = DateTimeOffset.UtcNow
|
||||
};
|
||||
traceTelemetry.Properties.Add("TestId", _testId);
|
||||
|
||||
// Act
|
||||
await _logger.LogAsync(traceTelemetry);
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public async Task LogTraceTelemetry_SendsCorrectDataWithWarningLevel()
|
||||
{
|
||||
// Arrange
|
||||
var traceTelemetry = new TraceTelemetry
|
||||
{
|
||||
Message = "Test trace warning message",
|
||||
SeverityLevel = SeverityLevel.Warning,
|
||||
Timestamp = DateTimeOffset.UtcNow
|
||||
};
|
||||
traceTelemetry.Properties.Add("TestId", _testId);
|
||||
|
||||
// Act
|
||||
await _logger.LogAsync(traceTelemetry);
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public async Task LogEventTelemetry_SendsCorrectData()
|
||||
{
|
||||
// Arrange
|
||||
var eventTelemetry = new EventTelemetry
|
||||
{
|
||||
Name = "Test Event",
|
||||
Timestamp = DateTimeOffset.UtcNow
|
||||
};
|
||||
eventTelemetry.Properties.Add("TestId", _testId);
|
||||
eventTelemetry.Metrics.Add("TestMetric", 42.0);
|
||||
|
||||
// Act
|
||||
await _logger.LogAsync(eventTelemetry);
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public async Task LogExceptionTelemetry_SendsCorrectData()
|
||||
{
|
||||
try
|
||||
{
|
||||
var t = 0;
|
||||
var result = 10 / t;
|
||||
}
|
||||
catch (Exception e)
|
||||
{
|
||||
// Arrange
|
||||
var exceptionTelemetry = new ExceptionTelemetry(e)
|
||||
{
|
||||
Timestamp = DateTimeOffset.UtcNow
|
||||
};
|
||||
exceptionTelemetry.Properties.Add("TestId", _testId);
|
||||
|
||||
// Act
|
||||
await _logger.LogAsync(exceptionTelemetry);
|
||||
}
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public async Task LogDependencyTelemetry_SendsCorrectData()
|
||||
{
|
||||
// Arrange
|
||||
var dependencyTelemetry = new DependencyTelemetry
|
||||
{
|
||||
Name = "SQL Query",
|
||||
Type = "SQL",
|
||||
Target = "TestDB",
|
||||
Success = true,
|
||||
Duration = TimeSpan.FromMilliseconds(100),
|
||||
Timestamp = DateTimeOffset.UtcNow
|
||||
};
|
||||
dependencyTelemetry.Properties.Add("TestId", _testId);
|
||||
|
||||
// Act
|
||||
await _logger.LogAsync(dependencyTelemetry);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// This is for scope test in SeqLogger. It is not testing anything related to the TelemetryChannel which logs to Seq.
|
||||
/// </summary>
|
||||
/// <returns></returns>
|
||||
[TestMethod]
|
||||
public async Task LogRequestTelemetryInOperationHolderWithParentChild_SendsCorrectData()
|
||||
{
|
||||
var telemetryClient = Container.Resolve<TelemetryClient>();
|
||||
|
||||
using (var parent = telemetryClient.StartOperation<RequestTelemetry>("Parent First"))
|
||||
{
|
||||
parent.Telemetry.Duration = TimeSpan.FromMilliseconds(250);
|
||||
parent.Telemetry.Url = new Uri("http://parent.test.com/api/test");
|
||||
|
||||
using (var child = telemetryClient.StartOperation<RequestTelemetry>("Child 1"))
|
||||
{
|
||||
child.Telemetry.Success = true;
|
||||
child.Telemetry.ResponseCode = "200";
|
||||
child.Telemetry.Duration = TimeSpan.FromMilliseconds(50);
|
||||
child.Telemetry.Url = new Uri("http://child.test.com/api/test");
|
||||
child.Telemetry.Timestamp = DateTimeOffset.UtcNow;
|
||||
|
||||
child.Telemetry.Properties.Add("httpMethod", HttpMethod.Get.ToString());
|
||||
child.Telemetry.Properties.Add("TestId", _testId);
|
||||
|
||||
await _logger.LogAsync(child);
|
||||
}
|
||||
|
||||
;
|
||||
|
||||
await _logger.LogAsync(parent);
|
||||
}
|
||||
}
|
||||
}
|
||||
60
Tests/Logging/SeqTelemetryChannelTest.cs
Normal file
60
Tests/Logging/SeqTelemetryChannelTest.cs
Normal file
|
|
@ -0,0 +1,60 @@
|
|||
using Autofac;
|
||||
using Microsoft.ApplicationInsights;
|
||||
using Microsoft.ApplicationInsights.Channel;
|
||||
using Microsoft.ApplicationInsights.DataContracts;
|
||||
using SWP.Core.SeqLogging;
|
||||
using SWP.Core.Telemetry;
|
||||
|
||||
namespace SWP.Core.X.TDD.Logging;
|
||||
|
||||
[TestClass]
|
||||
public class SeqTelemetryChannelTest : TestFixture
|
||||
{
|
||||
private CancellationTokenSource _cts;
|
||||
private IMessageChannel<ITelemetry> _messageChannel;
|
||||
private SeqBackgroundService _service;
|
||||
private TelemetryClient _telemetryClient;
|
||||
|
||||
[TestInitialize]
|
||||
public void SetupThis()
|
||||
{
|
||||
//it is important to use the same MessageChannel as the BackgroundService uses
|
||||
//we know that IMessageChannel<ITelemetry> _messageChannel; is registered via Autofac and manually injected into SeqBackgroundService
|
||||
//so we can get it by calling the Autofac Container in this test.
|
||||
|
||||
_messageChannel = Container.Resolve<IMessageChannel<ITelemetry>>();
|
||||
_service = Container.Resolve<SeqBackgroundService>();
|
||||
_telemetryClient = Container.Resolve<TelemetryClient>();
|
||||
|
||||
_cts = new CancellationTokenSource();
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public async Task Messages_ShouldBeProcessedFromQueue()
|
||||
{
|
||||
await _service.StartAsync(_cts.Token);
|
||||
|
||||
for (var i = 0; i < 5; i++)
|
||||
{
|
||||
var eventTelemetry = new EventTelemetry
|
||||
{
|
||||
Name = "Test Event 3",
|
||||
Timestamp = DateTimeOffset.UtcNow
|
||||
};
|
||||
|
||||
eventTelemetry.Properties.Add("TestId", Guid.NewGuid().ToString());
|
||||
eventTelemetry.Metrics.Add("TestMetric", 42.0);
|
||||
|
||||
//we don't write to the _messageChannel.Writer.WriteAsync(eventTelemetry);, but the TelemetryClient which is configured to use SeqTelemetryChannel
|
||||
_telemetryClient.TrackEvent(eventTelemetry);
|
||||
}
|
||||
|
||||
// wait for processing
|
||||
await Task.Delay(5000);
|
||||
|
||||
await _service.StopAsync(CancellationToken.None);
|
||||
|
||||
var hasMoreMessages = await _messageChannel.Reader.WaitToReadAsync();
|
||||
Assert.IsFalse(hasMoreMessages, "Queue should be empty after 5 seconds");
|
||||
}
|
||||
}
|
||||
86
Tests/PasswordHasherTest.cs
Normal file
86
Tests/PasswordHasherTest.cs
Normal file
|
|
@ -0,0 +1,86 @@
|
|||
using System.Diagnostics;
|
||||
using System.Text;
|
||||
using Sodium;
|
||||
|
||||
namespace SWP.Core.X.TDD;
|
||||
|
||||
[TestClass]
|
||||
public class PasswordHasherTests : TestFixture
|
||||
{
|
||||
[TestMethod]
|
||||
public void MyTestMethod()
|
||||
{
|
||||
var stopwatch = Stopwatch.StartNew();
|
||||
|
||||
var salt = PasswordHash.ScryptGenerateSalt();
|
||||
|
||||
// 2. Konverter password til byte[]
|
||||
var passwordBytes = Encoding.UTF8.GetBytes("password123");
|
||||
|
||||
// 3. Kald ScryptHashBinary korrekt
|
||||
var hash = PasswordHash.ScryptHashBinary(
|
||||
passwordBytes,
|
||||
salt
|
||||
);
|
||||
|
||||
stopwatch.Stop();
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void HashPassword_ShouldCreateValidHashFormat()
|
||||
{
|
||||
// Arrange
|
||||
var password = "TestPassword123";
|
||||
|
||||
// Act
|
||||
var hashedPassword = new SecureTokenizer().TokenizeText(password);
|
||||
var parts = hashedPassword.Split('.');
|
||||
|
||||
// Assert
|
||||
Assert.AreEqual(3, parts.Length);
|
||||
Assert.AreEqual("100000", parts[0]);
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void VerifyPassword_WithCorrectPassword_ShouldReturnTrue()
|
||||
{
|
||||
// Arrange
|
||||
var password = "TestPassword123";
|
||||
var hashedPassword = new SecureTokenizer().TokenizeText(password);
|
||||
|
||||
// Act
|
||||
var result = new SecureTokenizer().VerifyToken(hashedPassword, password);
|
||||
|
||||
// Assert
|
||||
Assert.IsTrue(result);
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void VerifyPassword_WithWrongPassword_ShouldReturnFalse()
|
||||
{
|
||||
// Arrange
|
||||
var correctPassword = "TestPassword123";
|
||||
var wrongPassword = "WrongPassword123";
|
||||
var hashedPassword = new SecureTokenizer().TokenizeText(correctPassword);
|
||||
|
||||
// Act
|
||||
var result = new SecureTokenizer().VerifyToken(hashedPassword, wrongPassword);
|
||||
|
||||
// Assert
|
||||
Assert.IsFalse(result);
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void VerifyPassword_WithInvalidHashFormat_ShouldReturnFalse()
|
||||
{
|
||||
// Arrange
|
||||
var password = "TestPassword123";
|
||||
var invalidHash = "InvalidHash";
|
||||
|
||||
// Act
|
||||
var result = new SecureTokenizer().VerifyToken(invalidHash, password);
|
||||
|
||||
// Assert
|
||||
Assert.IsFalse(result);
|
||||
}
|
||||
}
|
||||
80
Tests/PostgresTests.cs
Normal file
80
Tests/PostgresTests.cs
Normal file
|
|
@ -0,0 +1,80 @@
|
|||
using Autofac;
|
||||
using Insight.Database;
|
||||
using Shouldly;
|
||||
using SWP.Core.Database;
|
||||
using SWP.Core.Database.ConnectionFactory;
|
||||
|
||||
namespace SWP.Core.X.TDD;
|
||||
|
||||
[TestClass]
|
||||
public class PostgresTests : TestFixture
|
||||
{
|
||||
private IDbConnectionFactory _connFactory;
|
||||
private IDatabaseOperations _databaseOperations;
|
||||
|
||||
[TestInitialize]
|
||||
public void MyTestMethod()
|
||||
{
|
||||
_connFactory = Container.Resolve<IDbConnectionFactory>();
|
||||
_databaseOperations = Container.Resolve<IDatabaseOperations>();
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void TestDefaultConnection()
|
||||
{
|
||||
//https://stackoverflow.com/questions/69169247/how-to-create-idbconnection-factory-using-autofac-for-dapper
|
||||
|
||||
using (var conn = _connFactory.Create())
|
||||
conn.ExecuteSql("SELECT 1 as p");
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public async Task TestScopeConnectionWithLogging()
|
||||
{
|
||||
using var db = _databaseOperations.CreateScope(nameof(TestScopeConnectionWithLogging));
|
||||
try
|
||||
{
|
||||
var user = await db.Connection.QuerySqlAsync<string>(
|
||||
"SELECT tablename FROM pg_tables limit 5");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
db.Error(ex);
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public async Task TestScopeConnectionWithErrorLogging()
|
||||
{
|
||||
using var db = _databaseOperations.CreateScope(nameof(TestScopeConnectionWithLogging));
|
||||
try
|
||||
{
|
||||
var user = await db.Connection.QuerySqlAsync<string>(
|
||||
"SELECT tablename FROM pg_tables limit 5");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
db.Error(ex);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
[TestMethod]
|
||||
public async Task TestSimpleDatabaseOperation()
|
||||
{
|
||||
try
|
||||
{
|
||||
await _databaseOperations.ExecuteAsync(async connection =>
|
||||
{
|
||||
return await connection.QuerySqlAsync<string>(
|
||||
"SELECT tablename FROM pg_tables limit 5");
|
||||
}, nameof(TestSimpleDatabaseOperation));
|
||||
}
|
||||
catch (Exception)
|
||||
{
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
40
Tests/SWP.Core.X.TDD.csproj
Normal file
40
Tests/SWP.Core.X.TDD.csproj
Normal file
|
|
@ -0,0 +1,40 @@
|
|||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net9.0</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
|
||||
<IsPackable>false</IsPackable>
|
||||
<IsTestProject>true</IsTestProject>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="coverlet.collector" Version="6.0.0" />
|
||||
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.8.0" />
|
||||
<PackageReference Include="moq" Version="4.20.72" />
|
||||
<PackageReference Include="MSTest.TestAdapter" Version="3.1.1" />
|
||||
<PackageReference Include="MSTest.TestFramework" Version="3.1.1" />
|
||||
<PackageReference Include="Shouldly" Version="4.3.0" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\Core\SWP.Core.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<Using Include="Microsoft.VisualStudio.TestTools.UnitTesting" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<None Update="appconfiguration.dev.json">
|
||||
<CopyToOutputDirectory>Always</CopyToOutputDirectory>
|
||||
</None>
|
||||
<None Update="ConfigurationTests\appconfiguration.dev.json">
|
||||
<CopyToOutputDirectory>Always</CopyToOutputDirectory>
|
||||
</None>
|
||||
<None Update="ConfigurationTests\appconfiguration.dev.json">
|
||||
<CopyToOutputDirectory>Always</CopyToOutputDirectory>
|
||||
</None>
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
1
Tests/SecureConnectionStringTests.cs
Normal file
1
Tests/SecureConnectionStringTests.cs
Normal file
|
|
@ -0,0 +1 @@
|
|||
namespace SWP.Core.X.TDD;
|
||||
86
Tests/Security/SecureTokenizerTests.cs
Normal file
86
Tests/Security/SecureTokenizerTests.cs
Normal file
|
|
@ -0,0 +1,86 @@
|
|||
using Microsoft.VisualStudio.TestTools.UnitTesting;
|
||||
using Shouldly;
|
||||
using SWP.Core;
|
||||
|
||||
namespace SWP.Core.X.TDD.Security;
|
||||
|
||||
[TestClass]
|
||||
public class SecureTokenizerTests
|
||||
{
|
||||
private ISecureTokenizer _tokenizer;
|
||||
|
||||
[TestInitialize]
|
||||
public void Setup()
|
||||
{
|
||||
_tokenizer = new SecureTokenizer();
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void TokenizeText_ShouldReturnNonEmptyString()
|
||||
{
|
||||
// Act
|
||||
var token = _tokenizer.TokenizeText("testPassword");
|
||||
|
||||
// Assert
|
||||
token.ShouldNotBeNullOrEmpty();
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void TokenizeText_ShouldReturnDifferentTokensForSamePassword()
|
||||
{
|
||||
// Arrange
|
||||
var password = "testPassword";
|
||||
|
||||
// Act
|
||||
var token1 = _tokenizer.TokenizeText(password);
|
||||
var token2 = _tokenizer.TokenizeText(password);
|
||||
|
||||
// Assert
|
||||
token1.ShouldNotBe(token2);
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void VerifyToken_ShouldReturnTrueForValidPassword()
|
||||
{
|
||||
// Arrange
|
||||
var password = "testPassword";
|
||||
var token = _tokenizer.TokenizeText(password);
|
||||
|
||||
// Act
|
||||
var result = _tokenizer.VerifyToken(token, password);
|
||||
|
||||
// Assert
|
||||
result.ShouldBeTrue();
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void VerifyToken_ShouldReturnFalseForInvalidPassword()
|
||||
{
|
||||
// Arrange
|
||||
var password = "testPassword";
|
||||
var token = _tokenizer.TokenizeText(password);
|
||||
|
||||
// Act
|
||||
var result = _tokenizer.VerifyToken(token, "wrongPassword");
|
||||
|
||||
// Assert
|
||||
result.ShouldBeFalse();
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void VerifyToken_ShouldReturnFalseForMalformedToken()
|
||||
{
|
||||
// Act & Assert
|
||||
_tokenizer.VerifyToken("invalid.token", "password").ShouldBeFalse();
|
||||
_tokenizer.VerifyToken("", "password").ShouldBeFalse();
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void VerifyToken_ShouldHandleNullInputs()
|
||||
{
|
||||
// Act & Assert
|
||||
Should.Throw<NullReferenceException>(() => _tokenizer.VerifyToken(null, "password"));
|
||||
// Note: Current implementation doesn't handle null inputs gracefully
|
||||
// This should be fixed in production code
|
||||
}
|
||||
}
|
||||
87
Tests/TestFixture.cs
Normal file
87
Tests/TestFixture.cs
Normal file
|
|
@ -0,0 +1,87 @@
|
|||
using System.Diagnostics;
|
||||
using Autofac;
|
||||
using Microsoft.ApplicationInsights;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using SWP.Core.Configurations;
|
||||
using SWP.Core.Configurations.JsonConfigProvider;
|
||||
using SWP.Core.Database.ModuleRegistry;
|
||||
using SWP.Core.ModuleRegistry;
|
||||
using SWP.Core.SeqLogging;
|
||||
|
||||
namespace SWP.Core.X.TDD;
|
||||
|
||||
/// <summary>
|
||||
/// Act as base class for tests. Avoids duplication of test setup code
|
||||
/// </summary>
|
||||
[TestClass]
|
||||
public abstract class TestFixture
|
||||
{
|
||||
private readonly string _configurationFilePath;
|
||||
|
||||
protected TestFixture() : this(null)
|
||||
{
|
||||
}
|
||||
|
||||
public TestFixture(string configurationFilePath)
|
||||
{
|
||||
if (configurationFilePath is not null)
|
||||
_configurationFilePath = configurationFilePath?.TrimEnd('/') + "/";
|
||||
|
||||
CreateContainerBuilder();
|
||||
Container = ContainerBuilder.Build();
|
||||
}
|
||||
|
||||
protected IContainer Container { get; private set; }
|
||||
protected ContainerBuilder ContainerBuilder { get; private set; }
|
||||
|
||||
public virtual IConfigurationRoot Configuration()
|
||||
{
|
||||
var configuration = new ConfigurationBuilder()
|
||||
.AddJsonFile($"{_configurationFilePath}appconfiguration.dev.json")
|
||||
.Build();
|
||||
|
||||
return configuration;
|
||||
}
|
||||
|
||||
protected virtual void CreateContainerBuilder()
|
||||
{
|
||||
var configuration = Configuration();
|
||||
var builder = new ContainerBuilder();
|
||||
|
||||
builder.RegisterGeneric(typeof(Logger<>))
|
||||
.As(typeof(ILogger<>))
|
||||
.SingleInstance();
|
||||
|
||||
|
||||
builder.RegisterModule(new DbPostgreSqlModule
|
||||
{
|
||||
ConnectionString = configuration.GetConnectionString("DefaultConnection")
|
||||
});
|
||||
|
||||
builder.RegisterModule(new TelemetryModule
|
||||
{
|
||||
TelemetryConfig = configuration.GetSection("ApplicationInsights").ToObject<TelemetryConfig>()
|
||||
});
|
||||
builder.RegisterModule(new SeqLoggingModule
|
||||
{
|
||||
SeqConfiguration = configuration.GetSection("SeqConfiguration").ToObject<SeqConfiguration>()
|
||||
});
|
||||
|
||||
builder.RegisterModule<SecurityModule>();
|
||||
|
||||
ContainerBuilder = builder;
|
||||
}
|
||||
|
||||
[TestCleanup]
|
||||
public void CleanUp()
|
||||
{
|
||||
Trace.Flush();
|
||||
var telemetryClient = Container.Resolve<TelemetryClient>();
|
||||
telemetryClient.Flush();
|
||||
|
||||
if (Container is null) return;
|
||||
|
||||
Container.Dispose();
|
||||
Container = null;
|
||||
}
|
||||
}
|
||||
33
Tests/TestHelpers/TestFixtureBase.cs
Normal file
33
Tests/TestHelpers/TestFixtureBase.cs
Normal file
|
|
@ -0,0 +1,33 @@
|
|||
using Microsoft.Extensions.Configuration;
|
||||
using SWP.Core.Entities.Users;
|
||||
|
||||
namespace SWP.Core.X.TDD.TestHelpers;
|
||||
|
||||
public static class TestDataBuilder
|
||||
{
|
||||
public static class Users
|
||||
{
|
||||
public static User CreateTestUser(string email = null)
|
||||
{
|
||||
return new User
|
||||
{
|
||||
Id = new Random().Next(1, 1000),
|
||||
Email = email ?? $"test{Guid.NewGuid()}@example.com",
|
||||
EmailConfirmed = false,
|
||||
CreatedDate = DateTime.UtcNow
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
public static class Configuration
|
||||
{
|
||||
public static Dictionary<string, string> CreateTestConfiguration()
|
||||
{
|
||||
return new Dictionary<string, string>
|
||||
{
|
||||
["Database:ConnectionString"] = "Host=localhost;Database=test",
|
||||
["Logging:Level"] = "Debug"
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
14
Tests/appconfiguration.dev.json
Normal file
14
Tests/appconfiguration.dev.json
Normal file
|
|
@ -0,0 +1,14 @@
|
|||
{
|
||||
"ConnectionStrings": {
|
||||
"DefaultConnection": "Host=192.168.1.57;Port=5432;Database=ptmain;User Id=sathumper;Password=3911;"
|
||||
},
|
||||
"ApplicationInsights": {
|
||||
"ConnectionString": "InstrumentationKey=07d2a2b9-5e8e-4924-836e-264f8438f6c5;IngestionEndpoint=https://northeurope-2.in.applicationinsights.azure.com/;LiveEndpoint=https://northeurope.livediagnostics.monitor.azure.com/;ApplicationId=56748c39-2fa3-4880-a1e2-24068e791548",
|
||||
"UseSeqLoggingTelemetryChannel": true
|
||||
},
|
||||
"SeqConfiguration": {
|
||||
"IngestionEndpoint": "http://localhost:5341",
|
||||
"ApiKey": null,
|
||||
"Environment": "MSTEST"
|
||||
}
|
||||
}
|
||||
39
build.sh
Normal file
39
build.sh
Normal file
|
|
@ -0,0 +1,39 @@
|
|||
#!/bin/bash
|
||||
|
||||
# Build script for SWP.Core
|
||||
# This script assumes .NET 9.0 SDK is installed
|
||||
|
||||
set -e
|
||||
|
||||
echo "Building SWP.Core Solution..."
|
||||
|
||||
# Check if dotnet is available
|
||||
if ! command -v dotnet &> /dev/null; then
|
||||
echo "Error: .NET SDK is not installed or not in PATH"
|
||||
echo "Please install .NET 9.0 SDK from https://dotnet.microsoft.com/download"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Check .NET version
|
||||
DOTNET_VERSION=$(dotnet --version)
|
||||
echo "Using .NET version: $DOTNET_VERSION"
|
||||
|
||||
# Restore packages
|
||||
echo "Restoring NuGet packages..."
|
||||
dotnet restore
|
||||
|
||||
# Build solution
|
||||
echo "Building solution in Release mode..."
|
||||
dotnet build --configuration Release --no-restore
|
||||
|
||||
# Run unit tests
|
||||
echo "Running unit tests..."
|
||||
dotnet test --configuration Release --no-build --filter "TestCategory!=Integration" --logger "console;verbosity=normal"
|
||||
|
||||
# Run integration tests (if database is available)
|
||||
if [ "$RUN_INTEGRATION_TESTS" = "true" ]; then
|
||||
echo "Running integration tests..."
|
||||
dotnet test --configuration Release --no-build --filter "TestCategory=Integration" --logger "console;verbosity=normal"
|
||||
fi
|
||||
|
||||
echo "Build completed successfully!"
|
||||
Loading…
Add table
Add a link
Reference in a new issue