commit bbcefcf76f52afe5166615e0b61df157430833f0 Author: Dominic Villemure Date: Sat Mar 9 20:25:30 2024 -0500 First commit. Include junk from template to remove diff --git a/.azure/bicep/main.bicep b/.azure/bicep/main.bicep new file mode 100644 index 0000000..c5582a9 --- /dev/null +++ b/.azure/bicep/main.bicep @@ -0,0 +1,362 @@ +@description('The location into which your Azure resources should be deployed.') +param location string = resourceGroup().location + +@description('Select the type of environment you want to provision. Allowed values are Production, Staging, and Development.') +@allowed([ + 'Production' + 'Staging' + 'Development' +]) +param environmentName string + +@description('A unique suffix to add to resource names that need to be globally unique.') +@maxLength(13) +param resourceNameSuffix string = uniqueString(resourceGroup().id) + +@description('The administrator login username for the SQL server.') +param sqlAdministratorUsername string + +@secure() +@description('The administrator login password for the SQL server.') +param sqlAdministratorPassword string + +@description('The name of the project.') +param projectName string + +// Define the environment configuration map. +var environmentConfigurationMap = { + Production: { + environmentAbbreviation: 'prd' + appServicePlan: { + sku: { + name: 'S1' + capacity: 1 + } + } + sqlDatabase: { + sku: { + name: 'Standard' + tier: 'Standard' + } + } + } + Staging: { + environmentAbbreviation: 'stg' + appServicePlan: { + sku: { + name: 'B1' + } + } + sqlDatabase: { + sku: { + name: 'Standard' + tier: 'Standard' + } + } + } + Development: { + environmentAbbreviation: 'dev' + appServicePlan: { + sku: { + name: 'B1' + } + } + sqlDatabase: { + sku: { + name: 'Standard' + tier: 'Standard' + } + } + } +} + +// Define the names for resources. +var environmentAbbreviation = environmentConfigurationMap[environmentName].environmentAbbreviation +var keyVaultName = 'kv-${projectName}-${environmentAbbreviation}' +var appServiceAppName = 'as-${projectName}-${resourceNameSuffix}-${environmentAbbreviation}' +var appServicePlanName = 'plan-${projectName}-${environmentAbbreviation}' +var logAnalyticsWorkspaceName = 'log-${projectName}-${environmentAbbreviation}' +var applicationInsightsName = 'appi-${projectName}-${environmentAbbreviation}' +var sqlServerName = 'sql-${projectName}-${resourceNameSuffix}-${environmentAbbreviation}' +var sqlDatabaseName = '${projectName}-${environmentAbbreviation}' + +// Define the SKUs for each component based on the environment type. +var appServicePlanSku = environmentConfigurationMap[environmentName].appServicePlan.sku +var sqlDatabaseSku = environmentConfigurationMap[environmentName].sqlDatabase.sku + +resource logAnalyticsWorkspace 'Microsoft.OperationalInsights/workspaces@2020-10-01' = { + name: logAnalyticsWorkspaceName + location: location + properties: { + sku: { + name: 'PerGB2018' + } + } +} + +resource keyVault 'Microsoft.KeyVault/vaults@2019-09-01' = { + name: keyVaultName + location: location + properties: { + enabledForTemplateDeployment: true + tenantId: subscription().tenantId + accessPolicies: [] + sku: { + name: 'standard' + family: 'A' + } + } +} + +resource keyVault_ConnectionStringSecret 'Microsoft.KeyVault/vaults/secrets@2019-09-01' = { + parent: keyVault + name: 'ConnectionStrings--DefaultConnection' + properties: { + value: 'Server=tcp:${sqlServer.properties.fullyQualifiedDomainName},1433;Initial Catalog=${sqlDatabaseName};Persist Security Info=False;User ID=${sqlAdministratorUsername};Password=${sqlAdministratorPassword};MultipleActiveResultSets=False;Encrypt=True;TrustServerCertificate=False;Connection Timeout=30;' + } + dependsOn: [ + sqlDatabase + ] +} + +resource keyVault_DiagnosticSettings 'Microsoft.Insights/diagnosticSettings@2021-05-01-preview' = { + scope: keyVault + name: 'keyVaultDiagnosticSettings' + properties: { + workspaceId: logAnalyticsWorkspace.id + logs: [ + { + category: 'AuditEvent' + enabled: true + } + ] + metrics: [ + { + category: 'AllMetrics' + enabled: true + } + ] + } +} + +resource appServicePlan 'Microsoft.Web/serverfarms@2021-01-15' = { + name: appServicePlanName + location: location + sku: appServicePlanSku +} + +resource appServicePlan_DiagnosticSettings 'Microsoft.Insights/diagnosticSettings@2021-05-01-preview' = { + scope: appServicePlan + name: 'appServicePlanDiagnosticSettings' + properties: { + workspaceId: logAnalyticsWorkspace.id + metrics: [ + { + category: 'AllMetrics' + enabled: true + } + ] + } +} + +resource appServiceApp 'Microsoft.Web/sites@2021-01-15' = { + name: appServiceAppName + location: location + identity: { + type: 'SystemAssigned' + } + properties: { + serverFarmId: appServicePlan.id + httpsOnly: true + siteConfig: { + healthCheckPath: '/health' + netFrameworkVersion: 'v7.0' + appSettings: [ + { + name: 'APPLICATIONINSIGHTS_CONNECTION_STRING' + value: applicationInsights.properties.ConnectionString + } + { + name: 'KeyVaultUri' + value: keyVault.properties.vaultUri + } + ] + } + } +} + +resource appServiceApp_DiagnosticSettings 'Microsoft.Insights/diagnosticSettings@2021-05-01-preview' = { + scope: appServiceApp + name: 'appServiceAppDiagnosticSettings' + properties: { + workspaceId: logAnalyticsWorkspace.id + logs: [ + { + category: 'AppServiceHTTPLogs' + enabled: true + } + { + category: 'AppServiceConsoleLogs' + enabled: true + } + { + category: 'AppServiceAppLogs' + enabled: true + } + { + category: 'AppServiceAuditLogs' + enabled: true + } + { + category: 'AppServiceIPSecAuditLogs' + enabled: true + } + { + category: 'AppServicePlatformLogs' + enabled: true + } + ] + metrics: [ + { + category: 'AllMetrics' + enabled: true + } + ] + } +} + +resource keyVault_AccessPolicy 'Microsoft.KeyVault/vaults/accessPolicies@2022-07-01' = { + parent: keyVault + name: 'add' + properties: { + accessPolicies: [ + { + tenantId: appServiceApp.identity.tenantId + objectId: appServiceApp.identity.principalId + permissions: { + keys: [ + 'Get' + ] + secrets: [ + 'Get' + 'List' + + ] + certificates: [ + 'Get' + 'List' + ] + } + } + ] + } +} + +resource applicationInsights 'Microsoft.Insights/components@2020-02-02' = { + name: applicationInsightsName + location: location + kind: 'web' + properties: { + Application_Type: 'web' + WorkspaceResourceId: logAnalyticsWorkspace.id + } +} + +resource sqlServer 'Microsoft.Sql/servers@2021-02-01-preview' = { + name: sqlServerName + location: location + properties: { + administratorLogin: sqlAdministratorUsername + administratorLoginPassword: sqlAdministratorPassword + } +} + +resource sqlDatabase 'Microsoft.Sql/servers/databases@2021-02-01-preview' = { + parent: sqlServer + name: sqlDatabaseName + location: location + sku: sqlDatabaseSku +} + +resource sqlServer_AuditingSettings 'Microsoft.Sql/servers/auditingSettings@2021-11-01-preview' = { + parent: sqlServer + name: 'default' + properties: { + state: 'Enabled' + isAzureMonitorTargetEnabled: true + } +} + +resource sqlServer_FirewallRule 'Microsoft.Sql/servers/firewallRules@2021-02-01-preview' = { + parent: sqlServer + name: 'AllowAllWindowsAzureIps' + properties: { + endIpAddress: '0.0.0.0' + startIpAddress: '0.0.0.0' + } +} + +resource sqlDatabase_DiagnosticSettings 'Microsoft.Insights/diagnosticSettings@2021-05-01-preview' = { + scope: sqlDatabase + name: 'sqlDatabaseDiagnosticSettings' + properties: { + workspaceId: logAnalyticsWorkspace.id + logs: [ + { + category: 'SQLInsights' + enabled: true + } + { + category: 'AutomaticTuning' + enabled: true + } + { + category: 'QueryStoreRuntimeStatistics' + enabled: true + } + { + category: 'QueryStoreWaitStatistics' + enabled: true + } + { + category: 'Errors' + enabled: true + } + { + category: 'DatabaseWaitStatistics' + enabled: true + } + { + category: 'Timeouts' + enabled: true + } + { + category: 'Blocks' + enabled: true + } + { + category: 'Deadlocks' + enabled: true + } + ] + metrics: [ + { + category: 'Basic' + enabled: true + } + { + category: 'InstanceAndAppAdvanced' + enabled: true + } + { + category: 'WorkloadManagement' + enabled: true + } + ] + } +} + +output appServiceAppName string = appServiceApp.name +output appServiceAppHostName string = appServiceApp.properties.defaultHostName +output sqlServerFullyQualifiedDomainName string = sqlServer.properties.fullyQualifiedDomainName +output sqlDatabaseName string = sqlDatabase.name diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..b6fb7e3 --- /dev/null +++ b/.editorconfig @@ -0,0 +1,382 @@ +root = true + +# All files +[*] +indent_style = space + +# Xml files +[*.{xml,csproj,props,targets,ruleset,nuspec,resx}] +indent_size = 2 + +# Javascript files +[*.js] +indent_size = 2 + +# Json files +[*.{json,config,nswag}] +indent_size = 2 + +# C# files +[*.cs] + +#### Core EditorConfig Options #### + +# Indentation and spacing +indent_size = 4 +tab_width = 4 + +# New line preferences +end_of_line = lf +insert_final_newline = true + +#### .NET Coding Conventions #### +[*.{cs,vb}] + +# Organize usings +dotnet_separate_import_directive_groups = false +dotnet_sort_system_directives_first = true +file_header_template = unset + +# this. and Me. preferences +dotnet_style_qualification_for_event = false:silent +dotnet_style_qualification_for_field = false:silent +dotnet_style_qualification_for_method = false:silent +dotnet_style_qualification_for_property = false:silent + +# Language keywords vs BCL types preferences +dotnet_style_predefined_type_for_locals_parameters_members = true:silent +dotnet_style_predefined_type_for_member_access = true:silent + +# Parentheses preferences +dotnet_style_parentheses_in_arithmetic_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 +dotnet_style_parentheses_in_relational_binary_operators = always_for_clarity:silent + +# Modifier preferences +dotnet_style_require_accessibility_modifiers = for_non_interface_members:silent + +# Expression-level preferences +dotnet_style_coalesce_expression = true:suggestion +dotnet_style_collection_initializer = true:suggestion +dotnet_style_explicit_tuple_names = true:suggestion +dotnet_style_null_propagation = true:suggestion +dotnet_style_object_initializer = true:suggestion +dotnet_style_operator_placement_when_wrapping = beginning_of_line +dotnet_style_prefer_auto_properties = true:suggestion +dotnet_style_prefer_compound_assignment = true:suggestion +dotnet_style_prefer_conditional_expression_over_assignment = true:suggestion +dotnet_style_prefer_conditional_expression_over_return = true:suggestion +dotnet_style_prefer_inferred_anonymous_type_member_names = true:suggestion +dotnet_style_prefer_inferred_tuple_names = true:suggestion +dotnet_style_prefer_is_null_check_over_reference_equality_method = true:suggestion +dotnet_style_prefer_simplified_boolean_expressions = true:suggestion +dotnet_style_prefer_simplified_interpolation = true:suggestion + +# Field preferences +dotnet_style_readonly_field = true:warning + +# Parameter preferences +dotnet_code_quality_unused_parameters = all:suggestion + +# Suppression preferences +dotnet_remove_unnecessary_suppression_exclusions = none + +#### C# Coding Conventions #### +[*.cs] + +# var preferences +csharp_style_var_elsewhere = false:silent +csharp_style_var_for_built_in_types = false:silent +csharp_style_var_when_type_is_apparent = false:silent + +# Expression-bodied members +csharp_style_expression_bodied_accessors = true:silent +csharp_style_expression_bodied_constructors = false:silent +csharp_style_expression_bodied_indexers = true:silent +csharp_style_expression_bodied_lambdas = true:suggestion +csharp_style_expression_bodied_local_functions = false:silent +csharp_style_expression_bodied_methods = false:silent +csharp_style_expression_bodied_operators = false:silent +csharp_style_expression_bodied_properties = true:silent + +# Pattern matching preferences +csharp_style_pattern_matching_over_as_with_null_check = true:suggestion +csharp_style_pattern_matching_over_is_with_cast_check = true:suggestion +csharp_style_prefer_not_pattern = true:suggestion +csharp_style_prefer_pattern_matching = true:silent +csharp_style_prefer_switch_expression = true:suggestion + +# Null-checking preferences +csharp_style_conditional_delegate_call = true:suggestion + +# Modifier preferences +csharp_prefer_static_local_function = true:warning +csharp_preferred_modifier_order = public,private,protected,internal,static,extern,new,virtual,abstract,sealed,override,readonly,unsafe,volatile,async:silent + +# Code-block preferences +csharp_prefer_braces = true:silent +csharp_prefer_simple_using_statement = true:suggestion + +# Expression-level preferences +csharp_prefer_simple_default_expression = true:suggestion +csharp_style_deconstructed_variable_declaration = true:suggestion +csharp_style_inlined_variable_declaration = true:suggestion +csharp_style_pattern_local_over_anonymous_function = true:suggestion +csharp_style_prefer_index_operator = true:suggestion +csharp_style_prefer_range_operator = true:suggestion +csharp_style_throw_expression = true:suggestion +csharp_style_unused_value_assignment_preference = discard_variable:suggestion +csharp_style_unused_value_expression_statement_preference = discard_variable:silent + +# 'using' directive preferences +csharp_using_directive_placement = outside_namespace:silent + +#### C# Formatting Rules #### + +# New line preferences +csharp_new_line_before_catch = true +csharp_new_line_before_else = true +csharp_new_line_before_finally = true +csharp_new_line_before_members_in_anonymous_types = true +csharp_new_line_before_members_in_object_initializers = true +csharp_new_line_before_open_brace = all +csharp_new_line_between_query_expression_clauses = true + +# Indentation preferences +csharp_indent_block_contents = true +csharp_indent_braces = false +csharp_indent_case_contents = true +csharp_indent_case_contents_when_block = true +csharp_indent_labels = one_less_than_current +csharp_indent_switch_labels = true + +# Space preferences +csharp_space_after_cast = false +csharp_space_after_colon_in_inheritance_clause = true +csharp_space_after_comma = true +csharp_space_after_dot = false +csharp_space_after_keywords_in_control_flow_statements = true +csharp_space_after_semicolon_in_for_statement = true +csharp_space_around_binary_operators = before_and_after +csharp_space_around_declaration_statements = false +csharp_space_before_colon_in_inheritance_clause = true +csharp_space_before_comma = false +csharp_space_before_dot = false +csharp_space_before_open_square_brackets = false +csharp_space_before_semicolon_in_for_statement = false +csharp_space_between_empty_square_brackets = false +csharp_space_between_method_call_empty_parameter_list_parentheses = false +csharp_space_between_method_call_name_and_opening_parenthesis = false +csharp_space_between_method_call_parameter_list_parentheses = false +csharp_space_between_method_declaration_empty_parameter_list_parentheses = false +csharp_space_between_method_declaration_name_and_open_parenthesis = false +csharp_space_between_method_declaration_parameter_list_parentheses = false +csharp_space_between_parentheses = false +csharp_space_between_square_brackets = false + +# Wrapping preferences +csharp_preserve_single_line_blocks = true +csharp_preserve_single_line_statements = true +csharp_style_namespace_declarations = file_scoped:silent +csharp_style_prefer_method_group_conversion = true:silent +csharp_style_prefer_top_level_statements = true:silent +csharp_style_prefer_primary_constructors = true:suggestion +csharp_style_prefer_null_check_over_type_check = true:suggestion +csharp_style_prefer_local_over_anonymous_function = true:suggestion +csharp_style_implicit_object_creation_when_type_is_apparent = true:suggestion +csharp_style_prefer_tuple_swap = true:suggestion +csharp_style_prefer_utf8_string_literals = true:suggestion + +#### Naming styles #### +[*.{cs,vb}] + +# Naming rules + +dotnet_naming_rule.types_and_namespaces_should_be_pascalcase.severity = suggestion +dotnet_naming_rule.types_and_namespaces_should_be_pascalcase.symbols = types_and_namespaces +dotnet_naming_rule.types_and_namespaces_should_be_pascalcase.style = pascalcase + +dotnet_naming_rule.interfaces_should_be_ipascalcase.severity = suggestion +dotnet_naming_rule.interfaces_should_be_ipascalcase.symbols = interfaces +dotnet_naming_rule.interfaces_should_be_ipascalcase.style = ipascalcase + +dotnet_naming_rule.type_parameters_should_be_tpascalcase.severity = suggestion +dotnet_naming_rule.type_parameters_should_be_tpascalcase.symbols = type_parameters +dotnet_naming_rule.type_parameters_should_be_tpascalcase.style = tpascalcase + +dotnet_naming_rule.methods_should_be_pascalcase.severity = suggestion +dotnet_naming_rule.methods_should_be_pascalcase.symbols = methods +dotnet_naming_rule.methods_should_be_pascalcase.style = pascalcase + +dotnet_naming_rule.properties_should_be_pascalcase.severity = suggestion +dotnet_naming_rule.properties_should_be_pascalcase.symbols = properties +dotnet_naming_rule.properties_should_be_pascalcase.style = pascalcase + +dotnet_naming_rule.events_should_be_pascalcase.severity = suggestion +dotnet_naming_rule.events_should_be_pascalcase.symbols = events +dotnet_naming_rule.events_should_be_pascalcase.style = pascalcase + +dotnet_naming_rule.local_variables_should_be_camelcase.severity = suggestion +dotnet_naming_rule.local_variables_should_be_camelcase.symbols = local_variables +dotnet_naming_rule.local_variables_should_be_camelcase.style = camelcase + +dotnet_naming_rule.local_constants_should_be_camelcase.severity = suggestion +dotnet_naming_rule.local_constants_should_be_camelcase.symbols = local_constants +dotnet_naming_rule.local_constants_should_be_camelcase.style = camelcase + +dotnet_naming_rule.parameters_should_be_camelcase.severity = suggestion +dotnet_naming_rule.parameters_should_be_camelcase.symbols = parameters +dotnet_naming_rule.parameters_should_be_camelcase.style = camelcase + +dotnet_naming_rule.public_fields_should_be_pascalcase.severity = suggestion +dotnet_naming_rule.public_fields_should_be_pascalcase.symbols = public_fields +dotnet_naming_rule.public_fields_should_be_pascalcase.style = pascalcase + +dotnet_naming_rule.private_fields_should_be__camelcase.severity = suggestion +dotnet_naming_rule.private_fields_should_be__camelcase.symbols = private_fields +dotnet_naming_rule.private_fields_should_be__camelcase.style = _camelcase + +dotnet_naming_rule.private_static_fields_should_be_s_camelcase.severity = suggestion +dotnet_naming_rule.private_static_fields_should_be_s_camelcase.symbols = private_static_fields +dotnet_naming_rule.private_static_fields_should_be_s_camelcase.style = s_camelcase + +dotnet_naming_rule.public_constant_fields_should_be_pascalcase.severity = suggestion +dotnet_naming_rule.public_constant_fields_should_be_pascalcase.symbols = public_constant_fields +dotnet_naming_rule.public_constant_fields_should_be_pascalcase.style = pascalcase + +dotnet_naming_rule.private_constant_fields_should_be_pascalcase.severity = suggestion +dotnet_naming_rule.private_constant_fields_should_be_pascalcase.symbols = private_constant_fields +dotnet_naming_rule.private_constant_fields_should_be_pascalcase.style = pascalcase + +dotnet_naming_rule.public_static_readonly_fields_should_be_pascalcase.severity = suggestion +dotnet_naming_rule.public_static_readonly_fields_should_be_pascalcase.symbols = public_static_readonly_fields +dotnet_naming_rule.public_static_readonly_fields_should_be_pascalcase.style = pascalcase + +dotnet_naming_rule.private_static_readonly_fields_should_be_pascalcase.severity = suggestion +dotnet_naming_rule.private_static_readonly_fields_should_be_pascalcase.symbols = private_static_readonly_fields +dotnet_naming_rule.private_static_readonly_fields_should_be_pascalcase.style = pascalcase + +dotnet_naming_rule.enums_should_be_pascalcase.severity = suggestion +dotnet_naming_rule.enums_should_be_pascalcase.symbols = enums +dotnet_naming_rule.enums_should_be_pascalcase.style = pascalcase + +dotnet_naming_rule.local_functions_should_be_pascalcase.severity = suggestion +dotnet_naming_rule.local_functions_should_be_pascalcase.symbols = local_functions +dotnet_naming_rule.local_functions_should_be_pascalcase.style = pascalcase + +dotnet_naming_rule.non_field_members_should_be_pascalcase.severity = suggestion +dotnet_naming_rule.non_field_members_should_be_pascalcase.symbols = non_field_members +dotnet_naming_rule.non_field_members_should_be_pascalcase.style = pascalcase + +# Symbol specifications + +dotnet_naming_symbols.interfaces.applicable_kinds = interface +dotnet_naming_symbols.interfaces.applicable_accessibilities = public, internal, private, protected, protected_internal, private_protected +dotnet_naming_symbols.interfaces.required_modifiers = + +dotnet_naming_symbols.enums.applicable_kinds = enum +dotnet_naming_symbols.enums.applicable_accessibilities = public, internal, private, protected, protected_internal, private_protected +dotnet_naming_symbols.enums.required_modifiers = + +dotnet_naming_symbols.events.applicable_kinds = event +dotnet_naming_symbols.events.applicable_accessibilities = public, internal, private, protected, protected_internal, private_protected +dotnet_naming_symbols.events.required_modifiers = + +dotnet_naming_symbols.methods.applicable_kinds = method +dotnet_naming_symbols.methods.applicable_accessibilities = public, internal, private, protected, protected_internal, private_protected +dotnet_naming_symbols.methods.required_modifiers = + +dotnet_naming_symbols.properties.applicable_kinds = property +dotnet_naming_symbols.properties.applicable_accessibilities = public, internal, private, protected, protected_internal, private_protected +dotnet_naming_symbols.properties.required_modifiers = + +dotnet_naming_symbols.public_fields.applicable_kinds = field +dotnet_naming_symbols.public_fields.applicable_accessibilities = public, internal +dotnet_naming_symbols.public_fields.required_modifiers = + +dotnet_naming_symbols.private_fields.applicable_kinds = field +dotnet_naming_symbols.private_fields.applicable_accessibilities = private, protected, protected_internal, private_protected +dotnet_naming_symbols.private_fields.required_modifiers = + +dotnet_naming_symbols.private_static_fields.applicable_kinds = field +dotnet_naming_symbols.private_static_fields.applicable_accessibilities = private, protected, protected_internal, private_protected +dotnet_naming_symbols.private_static_fields.required_modifiers = static + +dotnet_naming_symbols.types_and_namespaces.applicable_kinds = namespace, class, struct, interface, enum +dotnet_naming_symbols.types_and_namespaces.applicable_accessibilities = public, internal, private, protected, protected_internal, private_protected +dotnet_naming_symbols.types_and_namespaces.required_modifiers = + +dotnet_naming_symbols.non_field_members.applicable_kinds = property, event, method +dotnet_naming_symbols.non_field_members.applicable_accessibilities = public, internal, private, protected, protected_internal, private_protected +dotnet_naming_symbols.non_field_members.required_modifiers = + +dotnet_naming_symbols.type_parameters.applicable_kinds = namespace +dotnet_naming_symbols.type_parameters.applicable_accessibilities = * +dotnet_naming_symbols.type_parameters.required_modifiers = + +dotnet_naming_symbols.private_constant_fields.applicable_kinds = field +dotnet_naming_symbols.private_constant_fields.applicable_accessibilities = private, protected, protected_internal, private_protected +dotnet_naming_symbols.private_constant_fields.required_modifiers = const + +dotnet_naming_symbols.local_variables.applicable_kinds = local +dotnet_naming_symbols.local_variables.applicable_accessibilities = local +dotnet_naming_symbols.local_variables.required_modifiers = + +dotnet_naming_symbols.local_constants.applicable_kinds = local +dotnet_naming_symbols.local_constants.applicable_accessibilities = local +dotnet_naming_symbols.local_constants.required_modifiers = const + +dotnet_naming_symbols.parameters.applicable_kinds = parameter +dotnet_naming_symbols.parameters.applicable_accessibilities = * +dotnet_naming_symbols.parameters.required_modifiers = + +dotnet_naming_symbols.public_constant_fields.applicable_kinds = field +dotnet_naming_symbols.public_constant_fields.applicable_accessibilities = public, internal +dotnet_naming_symbols.public_constant_fields.required_modifiers = const + +dotnet_naming_symbols.public_static_readonly_fields.applicable_kinds = field +dotnet_naming_symbols.public_static_readonly_fields.applicable_accessibilities = public, internal +dotnet_naming_symbols.public_static_readonly_fields.required_modifiers = readonly, static + +dotnet_naming_symbols.private_static_readonly_fields.applicable_kinds = field +dotnet_naming_symbols.private_static_readonly_fields.applicable_accessibilities = private, protected, protected_internal, private_protected +dotnet_naming_symbols.private_static_readonly_fields.required_modifiers = readonly, static + +dotnet_naming_symbols.local_functions.applicable_kinds = local_function +dotnet_naming_symbols.local_functions.applicable_accessibilities = * +dotnet_naming_symbols.local_functions.required_modifiers = + +# Naming styles + +dotnet_naming_style.pascalcase.required_prefix = +dotnet_naming_style.pascalcase.required_suffix = +dotnet_naming_style.pascalcase.word_separator = +dotnet_naming_style.pascalcase.capitalization = pascal_case + +dotnet_naming_style.ipascalcase.required_prefix = I +dotnet_naming_style.ipascalcase.required_suffix = +dotnet_naming_style.ipascalcase.word_separator = +dotnet_naming_style.ipascalcase.capitalization = pascal_case + +dotnet_naming_style.tpascalcase.required_prefix = T +dotnet_naming_style.tpascalcase.required_suffix = +dotnet_naming_style.tpascalcase.word_separator = +dotnet_naming_style.tpascalcase.capitalization = pascal_case + +dotnet_naming_style._camelcase.required_prefix = _ +dotnet_naming_style._camelcase.required_suffix = +dotnet_naming_style._camelcase.word_separator = +dotnet_naming_style._camelcase.capitalization = camel_case + +dotnet_naming_style.camelcase.required_prefix = +dotnet_naming_style.camelcase.required_suffix = +dotnet_naming_style.camelcase.word_separator = +dotnet_naming_style.camelcase.capitalization = camel_case + +dotnet_naming_style.s_camelcase.required_prefix = s_ +dotnet_naming_style.s_camelcase.required_suffix = +dotnet_naming_style.s_camelcase.word_separator = +dotnet_naming_style.s_camelcase.capitalization = camel_case + +dotnet_style_namespace_match_folder = true:suggestion diff --git a/.github/FUNDING.yml b/.github/FUNDING.yml new file mode 100644 index 0000000..27f72a1 --- /dev/null +++ b/.github/FUNDING.yml @@ -0,0 +1,13 @@ +# These are supported funding model platforms + +github: JasonTaylorDev # Replace with up to 4 GitHub Sponsors-enabled usernames e.g., [user1, user2] +# patreon: # Replace with a single Patreon username +# open_collective: # Replace with a single Open Collective username +# ko_fi: # Replace with a single Ko-fi username +# tidelift: # Replace with a single Tidelift platform-name/package-name e.g., npm/babel +# community_bridge: # Replace with a single Community Bridge project-name e.g., cloud-foundry +# liberapay: # Replace with a single Liberapay username +# issuehunt: # Replace with a single IssueHunt username +# otechie: # Replace with a single Otechie username +# lfx_crowdfunding: # Replace with a single LFX Crowdfunding project-name e.g., cloud-foundry +# custom: # Replace with up to 4 custom sponsorship URLs e.g., ['link1', 'link2'] diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml new file mode 100644 index 0000000..ce81956 --- /dev/null +++ b/.github/workflows/build.yml @@ -0,0 +1,82 @@ +name: Build + +on: + pull_request: + branches: [ main ] + paths-ignore: + - '.scripts/**' + - .gitignore + - CODE_OF_CONDUCT.md + - LICENSE + - README.md + + workflow_call: + inputs: + build-artifacts: + type: boolean + required: true + default: false + +jobs: + build: + + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v3 + name: Checkout code + + - name: Cache NuGet packages + uses: actions/cache@v3 + with: + path: ~/.nuget/packages + key: ${{ runner.os }}-nuget-${{ hashFiles('**/packages.lock.json') }} + restore-keys: | + ${{ runner.os }}-nuget- + + + - name: Install .NET + uses: actions/setup-dotnet@v3 + + - name: Restore solution + run: dotnet restore + + - name: Build solution + run: dotnet build --no-restore --configuration Release + + - name: Test solution + run: dotnet test --no-build --configuration Release --filter "FullyQualifiedName!~AcceptanceTests" + + - name: Publish website + if: ${{ inputs.build-artifacts == true }} + run: | + dotnet publish --configuration Release --runtime win-x86 --self-contained --output ./publish + cd publish + zip -r ./publish.zip . + working-directory: ./src/Web/ + + - name: Upload website artifact (website) + if: ${{ inputs.build-artifacts == true }} + uses: actions/upload-artifact@v3 + with: + name: website + path: ./src/Web/publish/publish.zip + if-no-files-found: error + + - name: Create EF Core migrations bundle + if: ${{ inputs.build-artifacts == true }} + run: | + dotnet new tool-manifest + dotnet tool install dotnet-ef + dotnet ef migrations bundle --configuration Release -p ./src/Infrastructure/ -s ./src/Web/ -o efbundle.exe + zip -r ./efbundle.zip efbundle.exe + env: + SkipNSwag: True + + - name: Upload EF Core migrations bundle artifact (efbundle) + if: ${{ inputs.build-artifacts == true }} + uses: actions/upload-artifact@v3 + with: + name: efbundle + path: ./efbundle.zip + if-no-files-found: error diff --git a/.github/workflows/cicd.yml b/.github/workflows/cicd.yml new file mode 100644 index 0000000..ce67ab4 --- /dev/null +++ b/.github/workflows/cicd.yml @@ -0,0 +1,42 @@ +name: CICD + +on: + push: + branches: [ main ] + paths-ignore: + - .gitignore + - CODE_OF_CONDUCT.md + - LICENSE + - README.md + +permissions: + id-token: write + contents: read + +jobs: + + build: + uses: ./.github/workflows/build.yml + with: + build-artifacts: true + + deploy-development: + uses: ./.github/workflows/deploy.yml + secrets: inherit + needs: [ build ] + with: + environmentName: Development + + deploy-staging: + uses: ./.github/workflows/deploy.yml + secrets: inherit + needs: [ deploy-development ] + with: + environmentName: Staging + + deploy-production: + uses: ./.github/workflows/deploy.yml + secrets: inherit + needs: [ deploy-staging ] + with: + environmentName: Production \ No newline at end of file diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml new file mode 100644 index 0000000..d9b02a8 --- /dev/null +++ b/.github/workflows/deploy.yml @@ -0,0 +1,107 @@ +name: Deploy + +on: + workflow_call: + inputs: + environmentName: + required: true + type: string + +permissions: + id-token: write + contents: read + +jobs: + + validate: + runs-on: ubuntu-latest + environment: ${{ inputs.environmentName }} + + steps: + + - uses: actions/checkout@v3 + name: Checkout code + + - uses: azure/login@v1 + name: Login to Azure + with: + client-id: ${{ vars.AZURE_CLIENT_ID }} + tenant-id: ${{ vars.AZURE_TENANT_ID }} + subscription-id: ${{ vars.AZURE_SUBSCRIPTION_ID }} + + - if: inputs.environmentName == 'Development' + uses: azure/arm-deploy@v1 + name: Run preflight validation + with: + deploymentName: ${{ github.run_number }} + resourceGroupName: ${{ vars.AZURE_RESOURCE_GROUP_NAME }} + template: ./.azure/bicep/main.bicep + parameters: > + environmentName=${{ inputs.environmentName }} + sqlAdministratorUsername=${{ vars.AZURE_SQL_ADMINISTRATOR_USERNAME }} + sqlAdministratorPassword=${{ secrets.AZURE_SQL_ADMINISTRATOR_PASSWORD }} + projectName=${{ vars.PROJECT_NAME }} + deploymentMode: Validate + + - if: inputs.environmentName != 'Development' + uses: azure/arm-deploy@v1 + name: Run what-if + with: + failOnStdErr: false + resourceGroupName: ${{ vars.AZURE_RESOURCE_GROUP_NAME }} + template: ./.azure/bicep/main.bicep + parameters: > + environmentName=${{ inputs.environmentName }} + sqlAdministratorUsername=${{ vars.AZURE_SQL_ADMINISTRATOR_USERNAME }} + sqlAdministratorPassword=${{ secrets.AZURE_SQL_ADMINISTRATOR_PASSWORD }} + projectName=${{ vars.PROJECT_NAME }} + additionalArguments: --what-if + + deploy: + needs: [ validate ] + runs-on: ubuntu-latest + environment: ${{ inputs.environmentName }} + + steps: + + - uses: actions/checkout@v3 + name: Checkout code + + - uses: actions/download-artifact@v3 + name: Download artifacts + + - name: Install .NET + uses: actions/setup-dotnet@v3 + + - uses: azure/login@v1 + name: Login to Azure + with: + client-id: ${{ vars.AZURE_CLIENT_ID }} + tenant-id: ${{ vars.AZURE_TENANT_ID }} + subscription-id: ${{ vars.AZURE_SUBSCRIPTION_ID }} + + - uses: azure/arm-deploy@v1 + id: deploy + name: Deploy infrastructure + with: + failOnStdErr: false + deploymentName: ${{ github.run_number }} + resourceGroupName: ${{ vars.AZURE_RESOURCE_GROUP_NAME }} + template: ./.azure/bicep/main.bicep + parameters: > + environmentName=${{ inputs.environmentName }} + sqlAdministratorUsername=${{ vars.AZURE_SQL_ADMINISTRATOR_USERNAME }} + sqlAdministratorPassword=${{ secrets.AZURE_SQL_ADMINISTRATOR_PASSWORD }} + projectName=${{ vars.PROJECT_NAME }} + + - name: Initialise database + run: | + unzip -o ./efbundle/efbundle.zip + echo '{ "ConnectionStrings": { "DefaultConnection": "" } }' > appsettings.json + ./efbundle.exe --connection "Server=${{ steps.deploy.outputs.sqlServerFullyQualifiedDomainName }};Initial Catalog=${{ steps.deploy.outputs.sqlDatabaseName }};Persist Security Info=False;User ID=${{ vars.AZURE_SQL_ADMINISTRATOR_USERNAME }};Password=${{ secrets.AZURE_SQL_ADMINISTRATOR_PASSWORD }};MultipleActiveResultSets=False;Encrypt=True;TrustServerCertificate=False;Connection Timeout=30;" --verbose + + - uses: azure/webapps-deploy@v2 + name: Deploy website + with: + app-name: ${{ steps.deploy.outputs.appServiceAppName }} + package: website/publish.zip diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..75f22e3 --- /dev/null +++ b/.gitignore @@ -0,0 +1,480 @@ +## Ignore Visual Studio temporary files, build results, and +## files generated by popular Visual Studio add-ons. +## +## Get latest from `dotnet new gitignore` + +# User-specific files +*.rsuser +*.suo +*.user +*.userosscache +*.sln.docstates + +# User-specific files (MonoDevelop/Xamarin Studio) +*.userprefs + +# Mono auto generated files +mono_crash.* + +# 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 +project.lock.json +project.fragment.lock.json +artifacts/ + +# Tye +.tye/ + +# 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/*.hhc +DocProject/Help/*.hhk +DocProject/Help/*.hhp +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 scripts will be unencrypted +PublishScripts/ + +# NuGet Packages +*.nupkg +# NuGet Symbol Packages +*.snupkg +# The packages folder can be ignored because of Package Restore +**/[Pp]ackages/* +# except build/, which is used as an MSBuild target. +!**/[Pp]ackages/build/ +# Uncomment if necessary however generally it will be regenerated when needed +#!**/[Pp]ackages/repositories.config +# NuGet v3's project.json files produces more ignorable files +*.nuget.props +*.nuget.targets + +# 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 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 +ServiceFabricBackup/ +*.rptproj.bak + +# 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 + +## +## Visual studio for Mac +## + + +# globs +Makefile.in +*.userprefs +*.usertasks +config.make +config.status +aclocal.m4 +install-sh +autom4te.cache/ +*.tar.gz +tarballs/ +test-results/ + +# Mac bundle stuff +*.dmg +*.app + +# content below from: https://github.com/github/gitignore/blob/master/Global/macOS.gitignore +# General +.DS_Store +.AppleDouble +.LSOverride + +# Icon must end with two \r +Icon + + +# Thumbnails +._* + +# Files that might appear in the root of a volume +.DocumentRevisions-V100 +.fseventsd +.Spotlight-V100 +.TemporaryItems +.Trashes +.VolumeIcon.icns +.com.apple.timemachine.donotpresent + +# Directories potentially created on remote AFP share +.AppleDB +.AppleDesktop +Network Trash Folder +Temporary Items +.apdisk + +# content below from: https://github.com/github/gitignore/blob/master/Global/Windows.gitignore +# Windows thumbnail cache files +Thumbs.db +ehthumbs.db +ehthumbs_vista.db + +# Dump file +*.stackdump + +# Folder config file +[Dd]esktop.ini + +# Recycle Bin used on file shares +$RECYCLE.BIN/ + +# Windows Installer files +*.cab +*.msi +*.msix +*.msm +*.msp + +# Windows shortcuts +*.lnk + +# Vim temporary swap files +*.swp diff --git a/.scripts/checks.ps1 b/.scripts/checks.ps1 new file mode 100644 index 0000000..5dbd5df --- /dev/null +++ b/.scripts/checks.ps1 @@ -0,0 +1,29 @@ +# Check if Azure CLI is installed +$azPath = (Get-Command az -ErrorAction SilentlyContinue).Source +if (-not $azPath) { + throw "Azure CLI (az) is not installed. Please install it and try again." +} + +# Check if Azure CLI is authenticated +az account show --output none +if ($LASTEXITCODE -ne 0) { + throw "Azure CLI (az) is not authenticated. Please authenticate with Azure CLI and try again." +} + +# Check if GitHub CLI is installed +$ghPath = (Get-Command gh -ErrorAction SilentlyContinue).Source +if (-not $ghPath) { + throw "GitHub CLI (gh) is not installed. Please install it and try again." +} + +# Check if GitHub CLI is authenticated +gh auth status | Out-Null +if ($LASTEXITCODE -ne 0) { + throw "GitHub CLI (gh) is not authenticated. Please authenticate with GitHub CLI and try again." +} + +# Check if Git repo is initialised +git status | Out-Null +if ($LASTEXITCODE -ne 0) { + throw "The Git repository has not been initialised. Please create a new GitHub repository and try again." +} \ No newline at end of file diff --git a/.scripts/cleanup.ps1 b/.scripts/cleanup.ps1 new file mode 100644 index 0000000..824909d --- /dev/null +++ b/.scripts/cleanup.ps1 @@ -0,0 +1,78 @@ +Param( + [String]$ProjectName +) + +$scriptRoot = Split-Path -Parent $MyInvocation.MyCommand.Path +$checksScript = Join-Path $scriptRoot "checks.ps1" + +try { + . $checksScript +} catch { + Write-Host $_.Exception.Message -ForegroundColor Red + Write-Host "Setup script terminated due to the checks failure." -ForegroundColor Red + exit 1 +} + +$MissingParameterValues = $false + +if (-not $ProjectName) { + $ProjectName = $(gh repo view --json name -q '.name' 2> $null) + if (-not $ProjectName) { $MissingParameterValues = $true } +} + +$ScriptParameters = @{ + "ProjectName" = $ProjectName +} + +Write-Host +Write-Host "This script performs cleanup operations to delete resource groups, Azure AD applications, and purge deleted key vaults associated with a specific project hosted on GitHub. It searches for resources based on the project name and performs the necessary deletion and purging actions. The script leverages the Azure CLI to interact with Azure resources. It aims to facilitate the cleanup process and remove unnecessary resources from your Azure environment." +Write-Host +Write-Host "Parameters:" -ForegroundColor Green +$ScriptParameters | Format-Table -AutoSize +Write-Host + +if ($MissingParameterValues) { + Write-Host "Script execution cancelled. Missing parameter values." -ForegroundColor Red + return +} + +Write-Host "Warning: This script will perform cleanup operations, including deleting resource groups, Azure AD applications, and purging deleted key vaults starting with the project name '$ProjectName'. Make sure you understand the consequences and have verified the project name before proceeding." -ForegroundColor Red +Write-Host +Write-Host "Disclaimer: Use this script at your own risk. The author and contributors are not responsible for any loss of data or unintended consequences resulting from running this script." -ForegroundColor Yellow +Write-Host + +$confirmation = Read-Host "Do you want to continue? (y/N)" + +if ($confirmation -ne "y") { + Write-Host "Script execution cancelled." -ForegroundColor Red + return +} + +Write-Host "🔍 Searching for Resource Groups..." +$resourceGroups = az group list --query "[?starts_with(name, '$ProjectName')].name" --output tsv + +foreach ($rg in $resourceGroups) { + Write-Host "🔥 Deleting: $rg" + az group delete --name $rg --yes > $null 2>&1 +} + +Write-Host "🔍 Searching for Azure AD Applications..." +$appRegistrations = az ad app list --display-name $ProjectName --query "[].{Name:displayName, AppId:appId}" --output json | ConvertFrom-Json + +foreach ($appRegistration in $appRegistrations) { + $appName = $appRegistration.Name + $appId = $appRegistration.AppId + + Write-Host "🔥 Deleting: $appName" + az ad app delete --id $appId > $null 2>&1 +} + +Write-Host "🔍 Searching for Deleted Key Vaults..." +$deletedKeyVaults = az keyvault list-deleted --query "[?starts_with(name, 'kv-$ProjectName')].name" --output tsv + +foreach ($vaultName in $deletedKeyVaults) { + Write-Host "🔥 Purging: $vaultName" + az keyvault purge --name $vaultName > $null 2>&1 +} + +Write-Host "✅ Done" diff --git a/.scripts/environments.json b/.scripts/environments.json new file mode 100644 index 0000000..917eb1b --- /dev/null +++ b/.scripts/environments.json @@ -0,0 +1,5 @@ +{ + "Dev": "Development", + "Stg": "Staging", + "Prd": "Production" +} diff --git a/.scripts/setup.ps1 b/.scripts/setup.ps1 new file mode 100644 index 0000000..5bea571 --- /dev/null +++ b/.scripts/setup.ps1 @@ -0,0 +1,210 @@ +Param( + [String]$GitHubOrganisationName, + [String]$GitHubRepositoryName, + [String]$AzureLocation, + [String]$AzureSubscriptionId, + [String]$AzureTenantId, + [ValidateLength(4, 17)] + [String]$ProjectName, + [String]$AzureSqlLogin = "SqlAdmin" +) + +$scriptRoot = Split-Path -Parent $MyInvocation.MyCommand.Path +$checksScript = Join-Path $scriptRoot "checks.ps1" +$environmentsFile = Join-Path $scriptRoot "environments.json" + +try { + . $checksScript +} catch { + Write-Host $_.Exception.Message -ForegroundColor Red + Write-Host "Setup script terminated due to the checks failure." -ForegroundColor Red + exit 1 +} + +$MissingParameterValues = $false + +if (-not $GitHubOrganisationName) { + $ownerJson = gh repo view --json owner 2>$null | ConvertFrom-Json + if ($ownerJson -and $ownerJson.owner -and $ownerJson.owner.login) { + $GitHubOrganisationName = $ownerJson.owner.login + } + else { + $MissingParameterValues = $true + } +} + +if (-not $GitHubRepositoryName) { + $GitHubRepositoryName = $(gh repo view --json name -q '.name' 2> $null) + if (-not $GitHubRepositoryName) { $MissingParameterValues = $true } +} + +if (-not $AzureLocation) { + $AzureLocation = "australiaeast" +} + +if (-not $AzureSubscriptionId) { + $AzureSubscriptionId = $(az account show --query id --output tsv 2> $null) + if (-not $AzureSubscriptionId) { $MissingParameterValues = $true } +} + +if (-not $AzureTenantId) { + $AzureTenantId = $(az account show --query tenantId --output tsv 2> $null) + if (-not $AzureTenantId) { $MissingParameterValues = $true } +} + +if (-not $ProjectName) { + if ($GitHubRepositoryName) { + $ProjectName = $GitHubRepositoryName + } + + if (-not $ProjectName) { $MissingParameterValues = $true } +} + +$repoUrl = "https://github.com/$GitHubOrganisationName/$GitHubRepositoryName" + +$environments = Get-Content -Raw -Path $environmentsFile | ConvertFrom-Json + +$ParametersTableData = @{ + "AzureLocation" = $AzureLocation + "AzureSubscriptionID" = $AzureSubscriptionId + "AzureTenantID" = $AzureTenantId + "GitHubOrganisationName" = $GitHubOrganisationName + "GitHubRepositoryName" = $GitHubRepositoryName + "ProjectName" = $ProjectName + "AzureSqlLogin" = $AzureSqlLogin +} + +Write-Host +Write-Host "This script automates the setup of environments, resources, and credentials for a project hosted on GitHub and deployed to Azure. It creates workload identities in Azure AD, sets up resource groups, and configures environment-specific variables and secrets in the GitHub repository. The script leverages the Azure CLI, GitHub CLI, and GitHub APIs to perform these tasks. It aims to streamline the process of setting up and configuring development, staging, and production environments for the project." +Write-Host +Write-Host "Parameters:" -ForegroundColor Green +$ParametersTableData | Format-Table -AutoSize + +if ($MissingParameterValues) { + Write-Host "Script execution cancelled. Missing parameter values." -ForegroundColor Red + exit 1 +} + +$EnvironmentTableData = foreach ($environment in $environments.PSObject.Properties) { + [PSCustomObject]@{ + Abbreviation = $environment.Name + Name = $environment.Value + } +} + +Write-Host "Environments:" -ForegroundColor Green +$EnvironmentTableData | Select-Object Name, Abbreviation | Format-Table -AutoSize +Write-Host + +Write-Host "Warning: Running this script will perform various operations in your GitHub repository and Azure subscription. Ensure that you have the necessary permissions and understand the consequences. " -ForegroundColor Red +Write-Host +Write-Host "Disclaimer: Use this script at your own risk. The author and contributors are not responsible for any loss of data or unintended consequences resulting from running this script." -ForegroundColor Yellow +Write-Host + +$confirmation = Read-Host "Do you want to continue? (y/N)" + +if ($confirmation -ne "y") { + Write-Host "Script execution cancelled." -ForegroundColor Red + return +} + +Write-Host + +function CreateWorkloadIdentity { + param ( + $environmentAbbr, + $environmentName + ) + + # Create Azure AD Application Registration + $applicationRegistrationDetails=$(az ad app create --display-name "$ProjectName$environmentAbbr") | ConvertFrom-Json + + # Create federated credentials + $credential = @{ + name="$ProjectName$environmentName"; + issuer="https://token.actions.githubusercontent.com"; + subject="repo:${GitHubOrganisationName}/${GitHubRepositoryName}:environment:$environmentName"; + audiences=@("api://AzureADTokenExchange") + } | ConvertTo-Json + + $credential | az ad app federated-credential create --id $applicationRegistrationDetails.id --parameters "@-" | Out-Null + + $credential = @{ + name="$ProjectName"; + issuer="https://token.actions.githubusercontent.com"; + subject="repo:${GitHubOrganisationName}/${GitHubRepositoryName}:ref:refs/heads/main"; + audiences=@("api://AzureADTokenExchange") + } | ConvertTo-Json + + $credential | az ad app federated-credential create --id $applicationRegistrationDetails.id --parameters "@-" | Out-Null + + return $applicationRegistrationDetails.appId +} + +function CreateResourceGroup { + param ( + $environmentAbbr, + $appId + ) + + $resourceGroupId = $(az group create --name "$ProjectName$environmentAbbr" --location $AzureLocation --query id --output tsv) + az ad sp create --id $appId + az role assignment create --assignee $appId --role Contributor --scope $resourceGroupId +} + +function CreateEnvironment { + param ( + $environmentName + ) + + $token = gh auth token + $header = @{"Authorization" = "token $token" } + $contentType = "application/json" + + $uri = "https://api.github.com/repos/$GitHubOrganisationName/$GitHubRepositoryName/environments/$environmentName" + Invoke-WebRequest -Method PUT -Header $header -ContentType $contentType -Uri $uri +} + +function GenerateRandomPassword { + param ( + [int]$Length = 16 + ) + + $ValidChars = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789!#^_-+=?<>|~".ToCharArray() + $Password = -join ((Get-Random -Count $Length -InputObject $ValidChars) | Get-Random -Count $Length) + + return $Password +} + +function SetVariables() { + gh variable set AZURE_TENANT_ID --body $AzureTenantId --repo $repoUrl + gh variable set AZURE_SUBSCRIPTION_ID --body $AzureSubscriptionId --repo $repoUrl + gh variable set PROJECT_NAME --body $ProjectName --repo $repoUrl +} + +function SetEnvironmentVariablesAndSecrets { + param( + $environmentAbbr, + $environmentName, + $appId + ) + + gh variable set AZURE_CLIENT_ID --body "$appId" --env $environmentName --repo $repoUrl + gh variable set AZURE_RESOURCE_GROUP_NAME --body "$ProjectName$environmentAbbr" --env $environmentName --repo $repoUrl + gh variable set AZURE_SQL_ADMINISTRATOR_USERNAME --body "$AzureSqlLogin" --env $environmentName --repo $repoUrl + gh secret set AZURE_SQL_ADMINISTRATOR_PASSWORD --body (GenerateRandomPassword) --env $environmentName --repo $repoUrl +} + +SetVariables + +foreach ($environment in $environments.PSObject.Properties) { + $environmentAbbr = $environment.Name + $environmentName = $environment.Value + + CreateEnvironment $environmentName + $appId = CreateWorkloadIdentity $environmentAbbr $environmentName + CreateResourceGroup $environmentAbbr $appId + SetEnvironmentVariablesAndSecrets $environmentAbbr $environmentName $appId +} + +Write-Host "✅ Done" \ No newline at end of file diff --git a/Directory.Build.props b/Directory.Build.props new file mode 100644 index 0000000..63a87da --- /dev/null +++ b/Directory.Build.props @@ -0,0 +1,10 @@ + + + + net8.0 + true + $(MSBuildThisFileDirectory)artifacts + enable + enable + + \ No newline at end of file diff --git a/Directory.Packages.props b/Directory.Packages.props new file mode 100644 index 0000000..864c732 --- /dev/null +++ b/Directory.Packages.props @@ -0,0 +1,38 @@ + + + + true + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/Hutopy.sln b/Hutopy.sln new file mode 100644 index 0000000..5714f45 --- /dev/null +++ b/Hutopy.sln @@ -0,0 +1,91 @@ + +Microsoft Visual Studio Solution File, Format Version 12.00 +# Visual Studio Version 17 +VisualStudioVersion = 17.0.31903.59 +MinimumVisualStudioVersion = 10.0.40219.1 +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Domain", "src\Domain\Domain.csproj", "{C7E89A3E-A631-4760-8D61-BD1EAB1C4E69}" +EndProject +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Application", "src\Application\Application.csproj", "{34C0FACD-F3D9-400C-8945-554DD6B0819A}" +EndProject +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Infrastructure", "src\Infrastructure\Infrastructure.csproj", "{117DA02F-5274-4565-ACC6-DA9B6E568B09}" +EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "src", "src", "{6ED356A7-8B47-4613-AD01-C85CF28491BD}" +EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "tests", "tests", "{664D406C-2F83-48F0-BFC3-408D5CB53C65}" +EndProject +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Application.UnitTests", "tests\Application.UnitTests\Application.UnitTests.csproj", "{DEFF4009-1FAB-4392-80B6-707E2DC5C00B}" +EndProject +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Domain.UnitTests", "tests\Domain.UnitTests\Domain.UnitTests.csproj", "{DC37FD87-552C-4613-9F16-1537CA522898}" +EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Solution Items", "Solution Items", "{E2DA20AA-28D1-455C-BF50-C49A8F831633}" + ProjectSection(SolutionItems) = preProject + .editorconfig = .editorconfig + .gitignore = .gitignore + Directory.Build.props = Directory.Build.props + Directory.Packages.props = Directory.Packages.props + global.json = global.json + README.md = README.md + EndProjectSection +EndProject +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Web", "src\Web\Web.csproj", "{4E4EE20C-F06A-4A1B-851F-C5577796941C}" +EndProject +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Application.FunctionalTests", "tests\Application.FunctionalTests\Application.FunctionalTests.csproj", "{EA6127A5-94C9-4C31-AD11-E6811B92B520}" +EndProject +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Infrastructure.IntegrationTests", "tests\Infrastructure.IntegrationTests\Infrastructure.IntegrationTests.csproj", "{01FA6786-921D-4CE8-8C50-4FDA66C9477D}" +EndProject +Global + GlobalSection(SolutionConfigurationPlatforms) = preSolution + Debug|Any CPU = Debug|Any CPU + Release|Any CPU = Release|Any CPU + EndGlobalSection + GlobalSection(ProjectConfigurationPlatforms) = postSolution + {C7E89A3E-A631-4760-8D61-BD1EAB1C4E69}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {C7E89A3E-A631-4760-8D61-BD1EAB1C4E69}.Debug|Any CPU.Build.0 = Debug|Any CPU + {C7E89A3E-A631-4760-8D61-BD1EAB1C4E69}.Release|Any CPU.ActiveCfg = Release|Any CPU + {C7E89A3E-A631-4760-8D61-BD1EAB1C4E69}.Release|Any CPU.Build.0 = Release|Any CPU + {34C0FACD-F3D9-400C-8945-554DD6B0819A}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {34C0FACD-F3D9-400C-8945-554DD6B0819A}.Debug|Any CPU.Build.0 = Debug|Any CPU + {34C0FACD-F3D9-400C-8945-554DD6B0819A}.Release|Any CPU.ActiveCfg = Release|Any CPU + {34C0FACD-F3D9-400C-8945-554DD6B0819A}.Release|Any CPU.Build.0 = Release|Any CPU + {117DA02F-5274-4565-ACC6-DA9B6E568B09}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {117DA02F-5274-4565-ACC6-DA9B6E568B09}.Debug|Any CPU.Build.0 = Debug|Any CPU + {117DA02F-5274-4565-ACC6-DA9B6E568B09}.Release|Any CPU.ActiveCfg = Release|Any CPU + {117DA02F-5274-4565-ACC6-DA9B6E568B09}.Release|Any CPU.Build.0 = Release|Any CPU + {DEFF4009-1FAB-4392-80B6-707E2DC5C00B}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {DEFF4009-1FAB-4392-80B6-707E2DC5C00B}.Debug|Any CPU.Build.0 = Debug|Any CPU + {DEFF4009-1FAB-4392-80B6-707E2DC5C00B}.Release|Any CPU.ActiveCfg = Release|Any CPU + {DEFF4009-1FAB-4392-80B6-707E2DC5C00B}.Release|Any CPU.Build.0 = Release|Any CPU + {DC37FD87-552C-4613-9F16-1537CA522898}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {DC37FD87-552C-4613-9F16-1537CA522898}.Debug|Any CPU.Build.0 = Debug|Any CPU + {DC37FD87-552C-4613-9F16-1537CA522898}.Release|Any CPU.ActiveCfg = Release|Any CPU + {DC37FD87-552C-4613-9F16-1537CA522898}.Release|Any CPU.Build.0 = Release|Any CPU + {4E4EE20C-F06A-4A1B-851F-C5577796941C}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {4E4EE20C-F06A-4A1B-851F-C5577796941C}.Debug|Any CPU.Build.0 = Debug|Any CPU + {4E4EE20C-F06A-4A1B-851F-C5577796941C}.Release|Any CPU.ActiveCfg = Release|Any CPU + {4E4EE20C-F06A-4A1B-851F-C5577796941C}.Release|Any CPU.Build.0 = Release|Any CPU + {EA6127A5-94C9-4C31-AD11-E6811B92B520}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {EA6127A5-94C9-4C31-AD11-E6811B92B520}.Debug|Any CPU.Build.0 = Debug|Any CPU + {EA6127A5-94C9-4C31-AD11-E6811B92B520}.Release|Any CPU.ActiveCfg = Release|Any CPU + {EA6127A5-94C9-4C31-AD11-E6811B92B520}.Release|Any CPU.Build.0 = Release|Any CPU + {01FA6786-921D-4CE8-8C50-4FDA66C9477D}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {01FA6786-921D-4CE8-8C50-4FDA66C9477D}.Debug|Any CPU.Build.0 = Debug|Any CPU + {01FA6786-921D-4CE8-8C50-4FDA66C9477D}.Release|Any CPU.ActiveCfg = Release|Any CPU + {01FA6786-921D-4CE8-8C50-4FDA66C9477D}.Release|Any CPU.Build.0 = Release|Any CPU + EndGlobalSection + GlobalSection(SolutionProperties) = preSolution + HideSolutionNode = FALSE + EndGlobalSection + GlobalSection(NestedProjects) = preSolution + {C7E89A3E-A631-4760-8D61-BD1EAB1C4E69} = {6ED356A7-8B47-4613-AD01-C85CF28491BD} + {34C0FACD-F3D9-400C-8945-554DD6B0819A} = {6ED356A7-8B47-4613-AD01-C85CF28491BD} + {117DA02F-5274-4565-ACC6-DA9B6E568B09} = {6ED356A7-8B47-4613-AD01-C85CF28491BD} + {DEFF4009-1FAB-4392-80B6-707E2DC5C00B} = {664D406C-2F83-48F0-BFC3-408D5CB53C65} + {DC37FD87-552C-4613-9F16-1537CA522898} = {664D406C-2F83-48F0-BFC3-408D5CB53C65} + {4E4EE20C-F06A-4A1B-851F-C5577796941C} = {6ED356A7-8B47-4613-AD01-C85CF28491BD} + {EA6127A5-94C9-4C31-AD11-E6811B92B520} = {664D406C-2F83-48F0-BFC3-408D5CB53C65} + {01FA6786-921D-4CE8-8C50-4FDA66C9477D} = {664D406C-2F83-48F0-BFC3-408D5CB53C65} + EndGlobalSection + GlobalSection(ExtensibilityGlobals) = postSolution + SolutionGuid = {3CB609D9-5D54-4C11-A371-DAAC8B74E430} + EndGlobalSection +EndGlobal diff --git a/README.md b/README.md new file mode 100644 index 0000000..2c86832 --- /dev/null +++ b/README.md @@ -0,0 +1,75 @@ +# Hutopy + +## Pattern used +- Clean Architecture +- Guards ( Fail fast ) : https://github.com/ardalis/GuardClauses + +## Tools +- Install Docker : https://www.docker.com/get-started/ +- Install sql server management ( or preffered tool ) : https://learn.microsoft.com/en-us/sql/ssms/download-sql-server-management-studio-ssms?view=sql-server-ver16#download-ssms + +## Database setup in docker for local dev +``` +docker run -e "ACCEPT_EULA=Y" -e "MSSQL_SA_PASSWORD=" -p 1433:1433 -d mcr.microsoft.com/mssql/server:2022-latest +``` + +Or with a mounted volume to persist data on the computer instead ( persist data even if the container is deleted ) +``` +docker run -e 'ACCEPT_EULA=Y' -e 'MSSQL_SA_PASSWORD=' -p 1433:1433 -v C:\dev\DockerVolumes\SqlServer-Utopy-1\data:/var/opt/mssql/data -v C:\dev\DockerVolumes\SqlServer-Utopy-1\log:/var/opt/mssql/log -v C:\dev\DockerVolumes\SqlServer-Utopy-1\secrets:/var/opt/mssql/secrets -d mcr.microsoft.com/mssql/server:2022-latest +``` + +Set your password in an env var for the connection string. Windows : $Env:DB_PASSWORD = "YourPassword" + +## Build + +Run `dotnet build -tl` to build the solution. + +## Run + +To run the web application: + +```bash +cd .\src\Web\ +dotnet watch run +``` + +Navigate to https://localhost:5001. The application will automatically reload if you change any of the source files. + +## Code Styles & Formatting + +The template includes [EditorConfig](https://editorconfig.org/) support to help maintain consistent coding styles for multiple developers working on the same project across various editors and IDEs. The **.editorconfig** file defines the coding styles applicable to this solution. + +## Code Scaffolding + +Scaffold new commands and queries. + +Start in the `.\src\Application\` folder. + +Create a new command: + +``` +dotnet new ca-usecase --name CreateTodoList --feature-name TodoLists --usecase-type command --return-type int +``` + +Create a new query: + +``` +dotnet new ca-usecase -n GetTodos -fn TodoLists -ut query -rt TodosVm +``` + +If you encounter the error *"No templates or subcommands found matching: 'ca-usecase'."*, install the template and try again: + +```bash +dotnet new install Clean.Architecture.Solution.Template::8.0.4 +``` + +## Test + +The solution contains unit, integration, and functional tests. + +- Using Moq, Nunit, Respawn, FluentAssertions + +To run the tests: +```bash +dotnet test +``` \ No newline at end of file diff --git a/global.json b/global.json new file mode 100644 index 0000000..501e79a --- /dev/null +++ b/global.json @@ -0,0 +1,6 @@ +{ + "sdk": { + "version": "8.0.100", + "rollForward": "latestFeature" + } +} \ No newline at end of file diff --git a/src/Application/Application.csproj b/src/Application/Application.csproj new file mode 100644 index 0000000..8544947 --- /dev/null +++ b/src/Application/Application.csproj @@ -0,0 +1,19 @@ + + + + Hutopy.Application + Hutopy.Application + + + + + + + + + + + + + + diff --git a/src/Application/Common/Behaviours/AuthorizationBehaviour.cs b/src/Application/Common/Behaviours/AuthorizationBehaviour.cs new file mode 100644 index 0000000..2b0378f --- /dev/null +++ b/src/Application/Common/Behaviours/AuthorizationBehaviour.cs @@ -0,0 +1,79 @@ +using System.Reflection; +using Hutopy.Application.Common.Exceptions; +using Hutopy.Application.Common.Interfaces; +using Hutopy.Application.Common.Security; + +namespace Hutopy.Application.Common.Behaviours; + +public class AuthorizationBehaviour : IPipelineBehavior where TRequest : notnull +{ + private readonly IUser _user; + private readonly IIdentityService _identityService; + + public AuthorizationBehaviour( + IUser user, + IIdentityService identityService) + { + _user = user; + _identityService = identityService; + } + + public async Task Handle(TRequest request, RequestHandlerDelegate next, CancellationToken cancellationToken) + { + var authorizeAttributes = request.GetType().GetCustomAttributes(); + + if (authorizeAttributes.Any()) + { + // Must be authenticated user + if (_user.Id == null) + { + throw new UnauthorizedAccessException(); + } + + // Role-based authorization + var authorizeAttributesWithRoles = authorizeAttributes.Where(a => !string.IsNullOrWhiteSpace(a.Roles)); + + if (authorizeAttributesWithRoles.Any()) + { + var authorized = false; + + foreach (var roles in authorizeAttributesWithRoles.Select(a => a.Roles.Split(','))) + { + foreach (var role in roles) + { + var isInRole = await _identityService.IsInRoleAsync(_user.Id, role.Trim()); + if (isInRole) + { + authorized = true; + break; + } + } + } + + // Must be a member of at least one role in roles + if (!authorized) + { + throw new ForbiddenAccessException(); + } + } + + // Policy-based authorization + var authorizeAttributesWithPolicies = authorizeAttributes.Where(a => !string.IsNullOrWhiteSpace(a.Policy)); + if (authorizeAttributesWithPolicies.Any()) + { + foreach (var policy in authorizeAttributesWithPolicies.Select(a => a.Policy)) + { + var authorized = await _identityService.AuthorizeAsync(_user.Id, policy); + + if (!authorized) + { + throw new ForbiddenAccessException(); + } + } + } + } + + // User is authorized / authorization not required + return await next(); + } +} diff --git a/src/Application/Common/Behaviours/LoggingBehaviour.cs b/src/Application/Common/Behaviours/LoggingBehaviour.cs new file mode 100644 index 0000000..70812ca --- /dev/null +++ b/src/Application/Common/Behaviours/LoggingBehaviour.cs @@ -0,0 +1,34 @@ +using Hutopy.Application.Common.Interfaces; +using MediatR.Pipeline; +using Microsoft.Extensions.Logging; + +namespace Hutopy.Application.Common.Behaviours; + +public class LoggingBehaviour : IRequestPreProcessor where TRequest : notnull +{ + private readonly ILogger _logger; + private readonly IUser _user; + private readonly IIdentityService _identityService; + + public LoggingBehaviour(ILogger logger, IUser user, IIdentityService identityService) + { + _logger = logger; + _user = user; + _identityService = identityService; + } + + public async Task Process(TRequest request, CancellationToken cancellationToken) + { + var requestName = typeof(TRequest).Name; + var userId = _user.Id ?? string.Empty; + string? userName = string.Empty; + + if (!string.IsNullOrEmpty(userId)) + { + userName = await _identityService.GetUserNameAsync(userId); + } + + _logger.LogInformation("Hutopy Request: {Name} {@UserId} {@UserName} {@Request}", + requestName, userId, userName, request); + } +} diff --git a/src/Application/Common/Behaviours/PerformanceBehaviour.cs b/src/Application/Common/Behaviours/PerformanceBehaviour.cs new file mode 100644 index 0000000..64e9b55 --- /dev/null +++ b/src/Application/Common/Behaviours/PerformanceBehaviour.cs @@ -0,0 +1,53 @@ +using System.Diagnostics; +using Hutopy.Application.Common.Interfaces; +using Microsoft.Extensions.Logging; + +namespace Hutopy.Application.Common.Behaviours; + +public class PerformanceBehaviour : IPipelineBehavior where TRequest : notnull +{ + private readonly Stopwatch _timer; + private readonly ILogger _logger; + private readonly IUser _user; + private readonly IIdentityService _identityService; + + public PerformanceBehaviour( + ILogger logger, + IUser user, + IIdentityService identityService) + { + _timer = new Stopwatch(); + + _logger = logger; + _user = user; + _identityService = identityService; + } + + public async Task Handle(TRequest request, RequestHandlerDelegate next, CancellationToken cancellationToken) + { + _timer.Start(); + + var response = await next(); + + _timer.Stop(); + + var elapsedMilliseconds = _timer.ElapsedMilliseconds; + + if (elapsedMilliseconds > 500) + { + var requestName = typeof(TRequest).Name; + var userId = _user.Id ?? string.Empty; + var userName = string.Empty; + + if (!string.IsNullOrEmpty(userId)) + { + userName = await _identityService.GetUserNameAsync(userId); + } + + _logger.LogWarning("Hutopy Long Running Request: {Name} ({ElapsedMilliseconds} milliseconds) {@UserId} {@UserName} {@Request}", + requestName, elapsedMilliseconds, userId, userName, request); + } + + return response; + } +} diff --git a/src/Application/Common/Behaviours/UnhandledExceptionBehaviour.cs b/src/Application/Common/Behaviours/UnhandledExceptionBehaviour.cs new file mode 100644 index 0000000..a79558a --- /dev/null +++ b/src/Application/Common/Behaviours/UnhandledExceptionBehaviour.cs @@ -0,0 +1,29 @@ +using Microsoft.Extensions.Logging; + +namespace Hutopy.Application.Common.Behaviours; + +public class UnhandledExceptionBehaviour : IPipelineBehavior where TRequest : notnull +{ + private readonly ILogger _logger; + + public UnhandledExceptionBehaviour(ILogger logger) + { + _logger = logger; + } + + public async Task Handle(TRequest request, RequestHandlerDelegate next, CancellationToken cancellationToken) + { + try + { + return await next(); + } + catch (Exception ex) + { + var requestName = typeof(TRequest).Name; + + _logger.LogError(ex, "Hutopy Request: Unhandled Exception for Request {Name} {@Request}", requestName, request); + + throw; + } + } +} diff --git a/src/Application/Common/Behaviours/ValidationBehaviour.cs b/src/Application/Common/Behaviours/ValidationBehaviour.cs new file mode 100644 index 0000000..262fce7 --- /dev/null +++ b/src/Application/Common/Behaviours/ValidationBehaviour.cs @@ -0,0 +1,35 @@ +using ValidationException = Hutopy.Application.Common.Exceptions.ValidationException; + +namespace Hutopy.Application.Common.Behaviours; + +public class ValidationBehaviour : IPipelineBehavior + where TRequest : notnull +{ + private readonly IEnumerable> _validators; + + public ValidationBehaviour(IEnumerable> validators) + { + _validators = validators; + } + + public async Task Handle(TRequest request, RequestHandlerDelegate next, CancellationToken cancellationToken) + { + if (_validators.Any()) + { + var context = new ValidationContext(request); + + var validationResults = await Task.WhenAll( + _validators.Select(v => + v.ValidateAsync(context, cancellationToken))); + + var failures = validationResults + .Where(r => r.Errors.Any()) + .SelectMany(r => r.Errors) + .ToList(); + + if (failures.Any()) + throw new ValidationException(failures); + } + return await next(); + } +} diff --git a/src/Application/Common/Exceptions/ForbiddenAccessException.cs b/src/Application/Common/Exceptions/ForbiddenAccessException.cs new file mode 100644 index 0000000..41f91eb --- /dev/null +++ b/src/Application/Common/Exceptions/ForbiddenAccessException.cs @@ -0,0 +1,6 @@ +namespace Hutopy.Application.Common.Exceptions; + +public class ForbiddenAccessException : Exception +{ + public ForbiddenAccessException() : base() { } +} diff --git a/src/Application/Common/Exceptions/ValidationException.cs b/src/Application/Common/Exceptions/ValidationException.cs new file mode 100644 index 0000000..1570fbc --- /dev/null +++ b/src/Application/Common/Exceptions/ValidationException.cs @@ -0,0 +1,22 @@ +using FluentValidation.Results; + +namespace Hutopy.Application.Common.Exceptions; + +public class ValidationException : Exception +{ + public ValidationException() + : base("One or more validation failures have occurred.") + { + Errors = new Dictionary(); + } + + public ValidationException(IEnumerable failures) + : this() + { + Errors = failures + .GroupBy(e => e.PropertyName, e => e.ErrorMessage) + .ToDictionary(failureGroup => failureGroup.Key, failureGroup => failureGroup.ToArray()); + } + + public IDictionary Errors { get; } +} diff --git a/src/Application/Common/Interfaces/IApplicationDbContext.cs b/src/Application/Common/Interfaces/IApplicationDbContext.cs new file mode 100644 index 0000000..5b7e5d9 --- /dev/null +++ b/src/Application/Common/Interfaces/IApplicationDbContext.cs @@ -0,0 +1,12 @@ +using Hutopy.Domain.Entities; + +namespace Hutopy.Application.Common.Interfaces; + +public interface IApplicationDbContext +{ + DbSet TodoLists { get; } + + DbSet TodoItems { get; } + + Task SaveChangesAsync(CancellationToken cancellationToken); +} diff --git a/src/Application/Common/Interfaces/IIdentityService.cs b/src/Application/Common/Interfaces/IIdentityService.cs new file mode 100644 index 0000000..5dbcb4c --- /dev/null +++ b/src/Application/Common/Interfaces/IIdentityService.cs @@ -0,0 +1,16 @@ +using Hutopy.Application.Common.Models; + +namespace Hutopy.Application.Common.Interfaces; + +public interface IIdentityService +{ + Task GetUserNameAsync(string userId); + + Task IsInRoleAsync(string userId, string role); + + Task AuthorizeAsync(string userId, string policyName); + + Task<(Result Result, string UserId)> CreateUserAsync(string userName, string password); + + Task DeleteUserAsync(string userId); +} diff --git a/src/Application/Common/Interfaces/IUser.cs b/src/Application/Common/Interfaces/IUser.cs new file mode 100644 index 0000000..f6112a1 --- /dev/null +++ b/src/Application/Common/Interfaces/IUser.cs @@ -0,0 +1,6 @@ +namespace Hutopy.Application.Common.Interfaces; + +public interface IUser +{ + string? Id { get; } +} diff --git a/src/Application/Common/Mappings/MappingExtensions.cs b/src/Application/Common/Mappings/MappingExtensions.cs new file mode 100644 index 0000000..bdbb707 --- /dev/null +++ b/src/Application/Common/Mappings/MappingExtensions.cs @@ -0,0 +1,12 @@ +using Hutopy.Application.Common.Models; + +namespace Hutopy.Application.Common.Mappings; + +public static class MappingExtensions +{ + public static Task> PaginatedListAsync(this IQueryable queryable, int pageNumber, int pageSize) where TDestination : class + => PaginatedList.CreateAsync(queryable.AsNoTracking(), pageNumber, pageSize); + + public static Task> ProjectToListAsync(this IQueryable queryable, IConfigurationProvider configuration) where TDestination : class + => queryable.ProjectTo(configuration).AsNoTracking().ToListAsync(); +} diff --git a/src/Application/Common/Models/LookupDto.cs b/src/Application/Common/Models/LookupDto.cs new file mode 100644 index 0000000..bde00c2 --- /dev/null +++ b/src/Application/Common/Models/LookupDto.cs @@ -0,0 +1,19 @@ +using Hutopy.Domain.Entities; + +namespace Hutopy.Application.Common.Models; + +public class LookupDto +{ + public int Id { get; init; } + + public string? Title { get; init; } + + private class Mapping : Profile + { + public Mapping() + { + CreateMap(); + CreateMap(); + } + } +} diff --git a/src/Application/Common/Models/PaginatedList.cs b/src/Application/Common/Models/PaginatedList.cs new file mode 100644 index 0000000..53636fa --- /dev/null +++ b/src/Application/Common/Models/PaginatedList.cs @@ -0,0 +1,29 @@ +namespace Hutopy.Application.Common.Models; + +public class PaginatedList +{ + public IReadOnlyCollection Items { get; } + public int PageNumber { get; } + public int TotalPages { get; } + public int TotalCount { get; } + + public PaginatedList(IReadOnlyCollection items, int count, int pageNumber, int pageSize) + { + PageNumber = pageNumber; + TotalPages = (int)Math.Ceiling(count / (double)pageSize); + TotalCount = count; + Items = items; + } + + public bool HasPreviousPage => PageNumber > 1; + + public bool HasNextPage => PageNumber < TotalPages; + + public static async Task> CreateAsync(IQueryable source, int pageNumber, int pageSize) + { + var count = await source.CountAsync(); + var items = await source.Skip((pageNumber - 1) * pageSize).Take(pageSize).ToListAsync(); + + return new PaginatedList(items, count, pageNumber, pageSize); + } +} diff --git a/src/Application/Common/Models/Result.cs b/src/Application/Common/Models/Result.cs new file mode 100644 index 0000000..f052240 --- /dev/null +++ b/src/Application/Common/Models/Result.cs @@ -0,0 +1,24 @@ +namespace Hutopy.Application.Common.Models; + +public class Result +{ + internal Result(bool succeeded, IEnumerable errors) + { + Succeeded = succeeded; + Errors = errors.ToArray(); + } + + public bool Succeeded { get; init; } + + public string[] Errors { get; init; } + + public static Result Success() + { + return new Result(true, Array.Empty()); + } + + public static Result Failure(IEnumerable errors) + { + return new Result(false, errors); + } +} diff --git a/src/Application/Common/Security/AuthorizeAttribute.cs b/src/Application/Common/Security/AuthorizeAttribute.cs new file mode 100644 index 0000000..6a8a965 --- /dev/null +++ b/src/Application/Common/Security/AuthorizeAttribute.cs @@ -0,0 +1,23 @@ +namespace Hutopy.Application.Common.Security; + +/// +/// Specifies the class this attribute is applied to requires authorization. +/// +[AttributeUsage(AttributeTargets.Class, AllowMultiple = true, Inherited = true)] +public class AuthorizeAttribute : Attribute +{ + /// + /// Initializes a new instance of the class. + /// + public AuthorizeAttribute() { } + + /// + /// Gets or sets a comma delimited list of roles that are allowed to access the resource. + /// + public string Roles { get; set; } = string.Empty; + + /// + /// Gets or sets the policy name that determines access to the resource. + /// + public string Policy { get; set; } = string.Empty; +} diff --git a/src/Application/DependencyInjection.cs b/src/Application/DependencyInjection.cs new file mode 100644 index 0000000..ebbe093 --- /dev/null +++ b/src/Application/DependencyInjection.cs @@ -0,0 +1,25 @@ +using System.Reflection; +using Hutopy.Application.Common.Behaviours; + +namespace Microsoft.Extensions.DependencyInjection; + +public static class DependencyInjection +{ + public static IServiceCollection AddApplicationServices(this IServiceCollection services) + { + services.AddAutoMapper(Assembly.GetExecutingAssembly()); + + services.AddValidatorsFromAssembly(Assembly.GetExecutingAssembly()); + + services.AddMediatR(cfg => + { + cfg.RegisterServicesFromAssembly(Assembly.GetExecutingAssembly()); + //cfg.AddBehavior(typeof(IPipelineBehavior<,>), typeof(UnhandledExceptionBehaviour<,>)); + //cfg.AddBehavior(typeof(IPipelineBehavior<,>), typeof(AuthorizationBehaviour<,>)); + //cfg.AddBehavior(typeof(IPipelineBehavior<,>), typeof(ValidationBehaviour<,>)); + //cfg.AddBehavior(typeof(IPipelineBehavior<,>), typeof(PerformanceBehaviour<,>)); + }); + + return services; + } +} diff --git a/src/Application/GlobalUsings.cs b/src/Application/GlobalUsings.cs new file mode 100644 index 0000000..fa904ba --- /dev/null +++ b/src/Application/GlobalUsings.cs @@ -0,0 +1,6 @@ +global using Ardalis.GuardClauses; +global using AutoMapper; +global using AutoMapper.QueryableExtensions; +global using Microsoft.EntityFrameworkCore; +global using FluentValidation; +global using MediatR; \ No newline at end of file diff --git a/src/Application/TodoItems/Commands/CreateTodoItem/CreateTodoItem.cs b/src/Application/TodoItems/Commands/CreateTodoItem/CreateTodoItem.cs new file mode 100644 index 0000000..a560109 --- /dev/null +++ b/src/Application/TodoItems/Commands/CreateTodoItem/CreateTodoItem.cs @@ -0,0 +1,40 @@ +using Hutopy.Application.Common.Interfaces; +using Hutopy.Domain.Entities; +using Hutopy.Domain.Events; + +namespace Hutopy.Application.TodoItems.Commands.CreateTodoItem; + +public record CreateTodoItemCommand : IRequest +{ + public int ListId { get; init; } + + public string? Title { get; init; } +} + +public class CreateTodoItemCommandHandler : IRequestHandler +{ + private readonly IApplicationDbContext _context; + + public CreateTodoItemCommandHandler(IApplicationDbContext context) + { + _context = context; + } + + public async Task Handle(CreateTodoItemCommand request, CancellationToken cancellationToken) + { + var entity = new TodoItem + { + ListId = request.ListId, + Title = request.Title, + Done = false + }; + + entity.AddDomainEvent(new TodoItemCreatedEvent(entity)); + + _context.TodoItems.Add(entity); + + await _context.SaveChangesAsync(cancellationToken); + + return entity.Id; + } +} diff --git a/src/Application/TodoItems/Commands/CreateTodoItem/CreateTodoItemCommandValidator.cs b/src/Application/TodoItems/Commands/CreateTodoItem/CreateTodoItemCommandValidator.cs new file mode 100644 index 0000000..e6d876a --- /dev/null +++ b/src/Application/TodoItems/Commands/CreateTodoItem/CreateTodoItemCommandValidator.cs @@ -0,0 +1,11 @@ +namespace Hutopy.Application.TodoItems.Commands.CreateTodoItem; + +public class CreateTodoItemCommandValidator : AbstractValidator +{ + public CreateTodoItemCommandValidator() + { + RuleFor(v => v.Title) + .MaximumLength(200) + .NotEmpty(); + } +} diff --git a/src/Application/TodoItems/Commands/DeleteTodoItem/DeleteTodoItem.cs b/src/Application/TodoItems/Commands/DeleteTodoItem/DeleteTodoItem.cs new file mode 100644 index 0000000..d08cb44 --- /dev/null +++ b/src/Application/TodoItems/Commands/DeleteTodoItem/DeleteTodoItem.cs @@ -0,0 +1,35 @@ +using Hutopy.Application.Common.Interfaces; +using Hutopy.Domain.Events; +using Hutopy.Application.Common.Security; +using Hutopy.Domain.Constants; + +namespace Hutopy.Application.TodoItems.Commands.DeleteTodoItem; + +[Authorize(Roles = Roles.Administrator)] +[Authorize(Policy = Policies.CanDelete)] +public record DeleteTodoItemCommand(int Id) : IRequest; + +public class DeleteTodoItemCommandHandler : IRequestHandler +{ + private readonly IApplicationDbContext _context; + + public DeleteTodoItemCommandHandler(IApplicationDbContext context) + { + _context = context; + } + + public async Task Handle(DeleteTodoItemCommand request, CancellationToken cancellationToken) + { + var entity = await _context.TodoItems + .FindAsync(new object[] { request.Id }, cancellationToken); + + Guard.Against.NotFound(request.Id, entity); + + _context.TodoItems.Remove(entity); + + entity.AddDomainEvent(new TodoItemDeletedEvent(entity)); + + await _context.SaveChangesAsync(cancellationToken); + } + +} diff --git a/src/Application/TodoItems/Commands/UpdateTodoItem/UpdateTodoItem.cs b/src/Application/TodoItems/Commands/UpdateTodoItem/UpdateTodoItem.cs new file mode 100644 index 0000000..9699859 --- /dev/null +++ b/src/Application/TodoItems/Commands/UpdateTodoItem/UpdateTodoItem.cs @@ -0,0 +1,35 @@ +using Hutopy.Application.Common.Interfaces; + +namespace Hutopy.Application.TodoItems.Commands.UpdateTodoItem; + +public record UpdateTodoItemCommand : IRequest +{ + public int Id { get; init; } + + public string? Title { get; init; } + + public bool Done { get; init; } +} + +public class UpdateTodoItemCommandHandler : IRequestHandler +{ + private readonly IApplicationDbContext _context; + + public UpdateTodoItemCommandHandler(IApplicationDbContext context) + { + _context = context; + } + + public async Task Handle(UpdateTodoItemCommand request, CancellationToken cancellationToken) + { + var entity = await _context.TodoItems + .FindAsync(new object[] { request.Id }, cancellationToken); + + Guard.Against.NotFound(request.Id, entity); + + entity.Title = request.Title; + entity.Done = request.Done; + + await _context.SaveChangesAsync(cancellationToken); + } +} diff --git a/src/Application/TodoItems/Commands/UpdateTodoItem/UpdateTodoItemCommandValidator.cs b/src/Application/TodoItems/Commands/UpdateTodoItem/UpdateTodoItemCommandValidator.cs new file mode 100644 index 0000000..38383c6 --- /dev/null +++ b/src/Application/TodoItems/Commands/UpdateTodoItem/UpdateTodoItemCommandValidator.cs @@ -0,0 +1,11 @@ +namespace Hutopy.Application.TodoItems.Commands.UpdateTodoItem; + +public class UpdateTodoItemCommandValidator : AbstractValidator +{ + public UpdateTodoItemCommandValidator() + { + RuleFor(v => v.Title) + .MaximumLength(200) + .NotEmpty(); + } +} diff --git a/src/Application/TodoItems/Commands/UpdateTodoItemDetail/UpdateTodoItemDetail.cs b/src/Application/TodoItems/Commands/UpdateTodoItemDetail/UpdateTodoItemDetail.cs new file mode 100644 index 0000000..f02b254 --- /dev/null +++ b/src/Application/TodoItems/Commands/UpdateTodoItemDetail/UpdateTodoItemDetail.cs @@ -0,0 +1,39 @@ +using Hutopy.Application.Common.Interfaces; +using Hutopy.Domain.Enums; + +namespace Hutopy.Application.TodoItems.Commands.UpdateTodoItemDetail; + +public record UpdateTodoItemDetailCommand : IRequest +{ + public int Id { get; init; } + + public int ListId { get; init; } + + public PriorityLevel Priority { get; init; } + + public string? Note { get; init; } +} + +public class UpdateTodoItemDetailCommandHandler : IRequestHandler +{ + private readonly IApplicationDbContext _context; + + public UpdateTodoItemDetailCommandHandler(IApplicationDbContext context) + { + _context = context; + } + + public async Task Handle(UpdateTodoItemDetailCommand request, CancellationToken cancellationToken) + { + var entity = await _context.TodoItems + .FindAsync(new object[] { request.Id }, cancellationToken); + + Guard.Against.NotFound(request.Id, entity); + + entity.ListId = request.ListId; + entity.Priority = request.Priority; + entity.Note = request.Note; + + await _context.SaveChangesAsync(cancellationToken); + } +} diff --git a/src/Application/TodoItems/EventHandlers/TodoItemCompletedEventHandler.cs b/src/Application/TodoItems/EventHandlers/TodoItemCompletedEventHandler.cs new file mode 100644 index 0000000..2a1a001 --- /dev/null +++ b/src/Application/TodoItems/EventHandlers/TodoItemCompletedEventHandler.cs @@ -0,0 +1,21 @@ +using Hutopy.Domain.Events; +using Microsoft.Extensions.Logging; + +namespace Hutopy.Application.TodoItems.EventHandlers; + +public class TodoItemCompletedEventHandler : INotificationHandler +{ + private readonly ILogger _logger; + + public TodoItemCompletedEventHandler(ILogger logger) + { + _logger = logger; + } + + public Task Handle(TodoItemCompletedEvent notification, CancellationToken cancellationToken) + { + _logger.LogInformation("Hutopy Domain Event: {DomainEvent}", notification.GetType().Name); + + return Task.CompletedTask; + } +} diff --git a/src/Application/TodoItems/EventHandlers/TodoItemCreatedEventHandler.cs b/src/Application/TodoItems/EventHandlers/TodoItemCreatedEventHandler.cs new file mode 100644 index 0000000..7e20348 --- /dev/null +++ b/src/Application/TodoItems/EventHandlers/TodoItemCreatedEventHandler.cs @@ -0,0 +1,21 @@ +using Hutopy.Domain.Events; +using Microsoft.Extensions.Logging; + +namespace Hutopy.Application.TodoItems.EventHandlers; + +public class TodoItemCreatedEventHandler : INotificationHandler +{ + private readonly ILogger _logger; + + public TodoItemCreatedEventHandler(ILogger logger) + { + _logger = logger; + } + + public Task Handle(TodoItemCreatedEvent notification, CancellationToken cancellationToken) + { + _logger.LogInformation("Hutopy Domain Event: {DomainEvent}", notification.GetType().Name); + + return Task.CompletedTask; + } +} diff --git a/src/Application/TodoItems/Queries/GetTodoItemsWithPagination/GetTodoItemsWithPagination.cs b/src/Application/TodoItems/Queries/GetTodoItemsWithPagination/GetTodoItemsWithPagination.cs new file mode 100644 index 0000000..a34f162 --- /dev/null +++ b/src/Application/TodoItems/Queries/GetTodoItemsWithPagination/GetTodoItemsWithPagination.cs @@ -0,0 +1,34 @@ +using Hutopy.Application.Common.Interfaces; +using Hutopy.Application.Common.Mappings; +using Hutopy.Application.Common.Models; + +namespace Hutopy.Application.TodoItems.Queries.GetTodoItemsWithPagination; + +public record GetTodoItemsWithPaginationQuery : IRequest> +{ + public int ListId { get; init; } + public int PageNumber { get; init; } = 1; + public int PageSize { get; init; } = 10; +} + +public class GetTodoItemsWithPaginationQueryHandler : IRequestHandler> +{ + private readonly IApplicationDbContext _context; + private readonly IMapper _mapper; + + public GetTodoItemsWithPaginationQueryHandler(IApplicationDbContext context, IMapper mapper) + { + _context = context; + _mapper = mapper; + } + + public async Task> Handle(GetTodoItemsWithPaginationQuery request, CancellationToken cancellationToken) + { + Console.WriteLine(request); + return await _context.TodoItems + .Where(x => x.ListId == request.ListId) + .OrderBy(x => x.Title) + .ProjectTo(_mapper.ConfigurationProvider) + .PaginatedListAsync(request.PageNumber, request.PageSize); + } +} diff --git a/src/Application/TodoItems/Queries/GetTodoItemsWithPagination/GetTodoItemsWithPaginationQueryValidator.cs b/src/Application/TodoItems/Queries/GetTodoItemsWithPagination/GetTodoItemsWithPaginationQueryValidator.cs new file mode 100644 index 0000000..99bfd24 --- /dev/null +++ b/src/Application/TodoItems/Queries/GetTodoItemsWithPagination/GetTodoItemsWithPaginationQueryValidator.cs @@ -0,0 +1,16 @@ +namespace Hutopy.Application.TodoItems.Queries.GetTodoItemsWithPagination; + +public class GetTodoItemsWithPaginationQueryValidator : AbstractValidator +{ + public GetTodoItemsWithPaginationQueryValidator() + { + RuleFor(x => x.ListId) + .NotEmpty().WithMessage("ListId is required."); + + RuleFor(x => x.PageNumber) + .GreaterThanOrEqualTo(1).WithMessage("PageNumber at least greater than or equal to 1."); + + RuleFor(x => x.PageSize) + .GreaterThanOrEqualTo(1).WithMessage("PageSize at least greater than or equal to 1."); + } +} diff --git a/src/Application/TodoItems/Queries/GetTodoItemsWithPagination/TodoItemBriefDto.cs b/src/Application/TodoItems/Queries/GetTodoItemsWithPagination/TodoItemBriefDto.cs new file mode 100644 index 0000000..b7c40a3 --- /dev/null +++ b/src/Application/TodoItems/Queries/GetTodoItemsWithPagination/TodoItemBriefDto.cs @@ -0,0 +1,22 @@ +using Hutopy.Domain.Entities; + +namespace Hutopy.Application.TodoItems.Queries.GetTodoItemsWithPagination; + +public class TodoItemBriefDto +{ + public int Id { get; init; } + + public int ListId { get; init; } + + public string? Title { get; init; } + + public bool Done { get; init; } + + private class Mapping : Profile + { + public Mapping() + { + CreateMap(); + } + } +} diff --git a/src/Application/TodoLists/Commands/CreateTodoList/CreateTodoList.cs b/src/Application/TodoLists/Commands/CreateTodoList/CreateTodoList.cs new file mode 100644 index 0000000..c557f94 --- /dev/null +++ b/src/Application/TodoLists/Commands/CreateTodoList/CreateTodoList.cs @@ -0,0 +1,32 @@ +using Hutopy.Application.Common.Interfaces; +using Hutopy.Domain.Entities; + +namespace Hutopy.Application.TodoLists.Commands.CreateTodoList; + +public record CreateTodoListCommand : IRequest +{ + public string? Title { get; init; } +} + +public class CreateTodoListCommandHandler : IRequestHandler +{ + private readonly IApplicationDbContext _context; + + public CreateTodoListCommandHandler(IApplicationDbContext context) + { + _context = context; + } + + public async Task Handle(CreateTodoListCommand request, CancellationToken cancellationToken) + { + var entity = new TodoList(); + + entity.Title = request.Title; + + _context.TodoLists.Add(entity); + + await _context.SaveChangesAsync(cancellationToken); + + return entity.Id; + } +} diff --git a/src/Application/TodoLists/Commands/CreateTodoList/CreateTodoListCommandValidator.cs b/src/Application/TodoLists/Commands/CreateTodoList/CreateTodoListCommandValidator.cs new file mode 100644 index 0000000..4c627b4 --- /dev/null +++ b/src/Application/TodoLists/Commands/CreateTodoList/CreateTodoListCommandValidator.cs @@ -0,0 +1,26 @@ +using Hutopy.Application.Common.Interfaces; + +namespace Hutopy.Application.TodoLists.Commands.CreateTodoList; + +public class CreateTodoListCommandValidator : AbstractValidator +{ + private readonly IApplicationDbContext _context; + + public CreateTodoListCommandValidator(IApplicationDbContext context) + { + _context = context; + + RuleFor(v => v.Title) + .NotEmpty() + .MaximumLength(200) + .MustAsync(BeUniqueTitle) + .WithMessage("'{PropertyName}' must be unique.") + .WithErrorCode("Unique"); + } + + public async Task BeUniqueTitle(string title, CancellationToken cancellationToken) + { + return await _context.TodoLists + .AllAsync(l => l.Title != title, cancellationToken); + } +} diff --git a/src/Application/TodoLists/Commands/DeleteTodoList/DeleteTodoList.cs b/src/Application/TodoLists/Commands/DeleteTodoList/DeleteTodoList.cs new file mode 100644 index 0000000..a5c3634 --- /dev/null +++ b/src/Application/TodoLists/Commands/DeleteTodoList/DeleteTodoList.cs @@ -0,0 +1,28 @@ +using Hutopy.Application.Common.Interfaces; + +namespace Hutopy.Application.TodoLists.Commands.DeleteTodoList; + +public record DeleteTodoListCommand(int Id) : IRequest; + +public class DeleteTodoListCommandHandler : IRequestHandler +{ + private readonly IApplicationDbContext _context; + + public DeleteTodoListCommandHandler(IApplicationDbContext context) + { + _context = context; + } + + public async Task Handle(DeleteTodoListCommand request, CancellationToken cancellationToken) + { + var entity = await _context.TodoLists + .Where(l => l.Id == request.Id) + .SingleOrDefaultAsync(cancellationToken); + + Guard.Against.NotFound(request.Id, entity); + + _context.TodoLists.Remove(entity); + + await _context.SaveChangesAsync(cancellationToken); + } +} diff --git a/src/Application/TodoLists/Commands/PurgeTodoLists/PurgeTodoLists.cs b/src/Application/TodoLists/Commands/PurgeTodoLists/PurgeTodoLists.cs new file mode 100644 index 0000000..9778b6a --- /dev/null +++ b/src/Application/TodoLists/Commands/PurgeTodoLists/PurgeTodoLists.cs @@ -0,0 +1,26 @@ +using Hutopy.Application.Common.Interfaces; +using Hutopy.Application.Common.Security; +using Hutopy.Domain.Constants; + +namespace Hutopy.Application.TodoLists.Commands.PurgeTodoLists; + +[Authorize(Roles = Roles.Administrator)] +[Authorize(Policy = Policies.CanPurge)] +public record PurgeTodoListsCommand : IRequest; + +public class PurgeTodoListsCommandHandler : IRequestHandler +{ + private readonly IApplicationDbContext _context; + + public PurgeTodoListsCommandHandler(IApplicationDbContext context) + { + _context = context; + } + + public async Task Handle(PurgeTodoListsCommand request, CancellationToken cancellationToken) + { + _context.TodoLists.RemoveRange(_context.TodoLists); + + await _context.SaveChangesAsync(cancellationToken); + } +} diff --git a/src/Application/TodoLists/Commands/UpdateTodoList/UpdateTodoList.cs b/src/Application/TodoLists/Commands/UpdateTodoList/UpdateTodoList.cs new file mode 100644 index 0000000..98b44e9 --- /dev/null +++ b/src/Application/TodoLists/Commands/UpdateTodoList/UpdateTodoList.cs @@ -0,0 +1,33 @@ +using Hutopy.Application.Common.Interfaces; + +namespace Hutopy.Application.TodoLists.Commands.UpdateTodoList; + +public record UpdateTodoListCommand : IRequest +{ + public int Id { get; init; } + + public string? Title { get; init; } +} + +public class UpdateTodoListCommandHandler : IRequestHandler +{ + private readonly IApplicationDbContext _context; + + public UpdateTodoListCommandHandler(IApplicationDbContext context) + { + _context = context; + } + + public async Task Handle(UpdateTodoListCommand request, CancellationToken cancellationToken) + { + var entity = await _context.TodoLists + .FindAsync(new object[] { request.Id }, cancellationToken); + + Guard.Against.NotFound(request.Id, entity); + + entity.Title = request.Title; + + await _context.SaveChangesAsync(cancellationToken); + + } +} diff --git a/src/Application/TodoLists/Commands/UpdateTodoList/UpdateTodoListCommandValidator.cs b/src/Application/TodoLists/Commands/UpdateTodoList/UpdateTodoListCommandValidator.cs new file mode 100644 index 0000000..2e7ef97 --- /dev/null +++ b/src/Application/TodoLists/Commands/UpdateTodoList/UpdateTodoListCommandValidator.cs @@ -0,0 +1,27 @@ +using Hutopy.Application.Common.Interfaces; + +namespace Hutopy.Application.TodoLists.Commands.UpdateTodoList; + +public class UpdateTodoListCommandValidator : AbstractValidator +{ + private readonly IApplicationDbContext _context; + + public UpdateTodoListCommandValidator(IApplicationDbContext context) + { + _context = context; + + RuleFor(v => v.Title) + .NotEmpty() + .MaximumLength(200) + .MustAsync(BeUniqueTitle) + .WithMessage("'{PropertyName}' must be unique.") + .WithErrorCode("Unique"); + } + + public async Task BeUniqueTitle(UpdateTodoListCommand model, string title, CancellationToken cancellationToken) + { + return await _context.TodoLists + .Where(l => l.Id != model.Id) + .AllAsync(l => l.Title != title, cancellationToken); + } +} diff --git a/src/Application/TodoLists/Queries/GetTodos/GetTodos.cs b/src/Application/TodoLists/Queries/GetTodos/GetTodos.cs new file mode 100644 index 0000000..cb44450 --- /dev/null +++ b/src/Application/TodoLists/Queries/GetTodos/GetTodos.cs @@ -0,0 +1,38 @@ +using Hutopy.Application.Common.Interfaces; +using Hutopy.Application.Common.Models; +using Hutopy.Application.Common.Security; +using Hutopy.Domain.Enums; + +namespace Hutopy.Application.TodoLists.Queries.GetTodos; + +[Authorize] +public record GetTodosQuery : IRequest; + +public class GetTodosQueryHandler : IRequestHandler +{ + private readonly IApplicationDbContext _context; + private readonly IMapper _mapper; + + public GetTodosQueryHandler(IApplicationDbContext context, IMapper mapper) + { + _context = context; + _mapper = mapper; + } + + public async Task Handle(GetTodosQuery request, CancellationToken cancellationToken) + { + return new TodosVm + { + PriorityLevels = Enum.GetValues(typeof(PriorityLevel)) + .Cast() + .Select(p => new LookupDto { Id = (int)p, Title = p.ToString() }) + .ToList(), + + Lists = await _context.TodoLists + .AsNoTracking() + .ProjectTo(_mapper.ConfigurationProvider) + .OrderBy(t => t.Title) + .ToListAsync(cancellationToken) + }; + } +} diff --git a/src/Application/TodoLists/Queries/GetTodos/TodoItemDto.cs b/src/Application/TodoLists/Queries/GetTodos/TodoItemDto.cs new file mode 100644 index 0000000..18d86a3 --- /dev/null +++ b/src/Application/TodoLists/Queries/GetTodos/TodoItemDto.cs @@ -0,0 +1,27 @@ +using Hutopy.Domain.Entities; + +namespace Hutopy.Application.TodoLists.Queries.GetTodos; + +public class TodoItemDto +{ + public int Id { get; init; } + + public int ListId { get; init; } + + public string? Title { get; init; } + + public bool Done { get; init; } + + public int Priority { get; init; } + + public string? Note { get; init; } + + private class Mapping : Profile + { + public Mapping() + { + CreateMap().ForMember(d => d.Priority, + opt => opt.MapFrom(s => (int)s.Priority)); + } + } +} diff --git a/src/Application/TodoLists/Queries/GetTodos/TodoListDto.cs b/src/Application/TodoLists/Queries/GetTodos/TodoListDto.cs new file mode 100644 index 0000000..6236f9a --- /dev/null +++ b/src/Application/TodoLists/Queries/GetTodos/TodoListDto.cs @@ -0,0 +1,27 @@ +using Hutopy.Domain.Entities; + +namespace Hutopy.Application.TodoLists.Queries.GetTodos; + +public class TodoListDto +{ + public TodoListDto() + { + Items = Array.Empty(); + } + + public int Id { get; init; } + + public string? Title { get; init; } + + public string? Colour { get; init; } + + public IReadOnlyCollection Items { get; init; } + + private class Mapping : Profile + { + public Mapping() + { + CreateMap(); + } + } +} diff --git a/src/Application/TodoLists/Queries/GetTodos/TodosVm.cs b/src/Application/TodoLists/Queries/GetTodos/TodosVm.cs new file mode 100644 index 0000000..6bff5af --- /dev/null +++ b/src/Application/TodoLists/Queries/GetTodos/TodosVm.cs @@ -0,0 +1,10 @@ +using Hutopy.Application.Common.Models; + +namespace Hutopy.Application.TodoLists.Queries.GetTodos; + +public class TodosVm +{ + public IReadOnlyCollection PriorityLevels { get; init; } = Array.Empty(); + + public IReadOnlyCollection Lists { get; init; } = Array.Empty(); +} diff --git a/src/Application/WeatherForecasts/Queries/GetWeatherForecasts/GetWeatherForecastsQuery.cs b/src/Application/WeatherForecasts/Queries/GetWeatherForecasts/GetWeatherForecastsQuery.cs new file mode 100644 index 0000000..cf75269 --- /dev/null +++ b/src/Application/WeatherForecasts/Queries/GetWeatherForecasts/GetWeatherForecastsQuery.cs @@ -0,0 +1,25 @@ +namespace Hutopy.Application.WeatherForecasts.Queries.GetWeatherForecasts; + +public record GetWeatherForecastsQuery : IRequest>; + +public class GetWeatherForecastsQueryHandler : IRequestHandler> +{ + private static readonly string[] Summaries = new[] + { + "Freezing", "Bracing", "Chilly", "Cool", "Mild", "Warm", "Balmy", "Hot", "Sweltering", "Scorching" + }; + +#pragma warning disable CS1998 // Async method lacks 'await' operators and will run synchronously + public async Task> Handle(GetWeatherForecastsQuery request, CancellationToken cancellationToken) +#pragma warning restore CS1998 // Async method lacks 'await' operators and will run synchronously + { + var rng = new Random(); + + return Enumerable.Range(1, 5).Select(index => new WeatherForecast + { + Date = DateTime.Now.AddDays(index), + TemperatureC = rng.Next(-20, 55), + Summary = Summaries[rng.Next(Summaries.Length)] + }); + } +} diff --git a/src/Application/WeatherForecasts/Queries/GetWeatherForecasts/WeatherForecast.cs b/src/Application/WeatherForecasts/Queries/GetWeatherForecasts/WeatherForecast.cs new file mode 100644 index 0000000..e01216a --- /dev/null +++ b/src/Application/WeatherForecasts/Queries/GetWeatherForecasts/WeatherForecast.cs @@ -0,0 +1,12 @@ +namespace Hutopy.Application.WeatherForecasts.Queries.GetWeatherForecasts; + +public class WeatherForecast +{ + public DateTime Date { get; init; } + + public int TemperatureC { get; init; } + + public int TemperatureF => 32 + (int)(TemperatureC / 0.5556); + + public string? Summary { get; init; } +} diff --git a/src/Domain/Common/BaseAuditableEntity.cs b/src/Domain/Common/BaseAuditableEntity.cs new file mode 100644 index 0000000..f1da955 --- /dev/null +++ b/src/Domain/Common/BaseAuditableEntity.cs @@ -0,0 +1,12 @@ +namespace Hutopy.Domain.Common; + +public abstract class BaseAuditableEntity : BaseEntity +{ + public DateTimeOffset Created { get; set; } + + public string? CreatedBy { get; set; } + + public DateTimeOffset LastModified { get; set; } + + public string? LastModifiedBy { get; set; } +} diff --git a/src/Domain/Common/BaseEntity.cs b/src/Domain/Common/BaseEntity.cs new file mode 100644 index 0000000..2ecc1fe --- /dev/null +++ b/src/Domain/Common/BaseEntity.cs @@ -0,0 +1,30 @@ +using System.ComponentModel.DataAnnotations.Schema; + +namespace Hutopy.Domain.Common; + +public abstract class BaseEntity +{ + // This can easily be modified to be BaseEntity and public T Id to support different key types. + // Using non-generic integer types for simplicity + public int Id { get; set; } + + private readonly List _domainEvents = new(); + + [NotMapped] + public IReadOnlyCollection DomainEvents => _domainEvents.AsReadOnly(); + + public void AddDomainEvent(BaseEvent domainEvent) + { + _domainEvents.Add(domainEvent); + } + + public void RemoveDomainEvent(BaseEvent domainEvent) + { + _domainEvents.Remove(domainEvent); + } + + public void ClearDomainEvents() + { + _domainEvents.Clear(); + } +} diff --git a/src/Domain/Common/BaseEvent.cs b/src/Domain/Common/BaseEvent.cs new file mode 100644 index 0000000..5df80ee --- /dev/null +++ b/src/Domain/Common/BaseEvent.cs @@ -0,0 +1,7 @@ +using MediatR; + +namespace Hutopy.Domain.Common; + +public abstract class BaseEvent : INotification +{ +} diff --git a/src/Domain/Common/ValueObject.cs b/src/Domain/Common/ValueObject.cs new file mode 100644 index 0000000..e419f3e --- /dev/null +++ b/src/Domain/Common/ValueObject.cs @@ -0,0 +1,45 @@ +namespace Hutopy.Domain.Common; + +// Learn more: https://docs.microsoft.com/en-us/dotnet/standard/microservices-architecture/microservice-ddd-cqrs-patterns/implement-value-objects +public abstract class ValueObject +{ + protected static bool EqualOperator(ValueObject left, ValueObject right) + { + if (left is null ^ right is null) + { + return false; + } + + return left?.Equals(right!) != false; + } + + protected static bool NotEqualOperator(ValueObject left, ValueObject right) + { + return !(EqualOperator(left, right)); + } + + protected abstract IEnumerable GetEqualityComponents(); + + public override bool Equals(object? obj) + { + if (obj == null || obj.GetType() != GetType()) + { + return false; + } + + var other = (ValueObject)obj; + return GetEqualityComponents().SequenceEqual(other.GetEqualityComponents()); + } + + public override int GetHashCode() + { + var hash = new HashCode(); + + foreach (var component in GetEqualityComponents()) + { + hash.Add(component); + } + + return hash.ToHashCode(); + } +} diff --git a/src/Domain/Constants/Policies.cs b/src/Domain/Constants/Policies.cs new file mode 100644 index 0000000..401d6dd --- /dev/null +++ b/src/Domain/Constants/Policies.cs @@ -0,0 +1,7 @@ +namespace Hutopy.Domain.Constants; + +public abstract class Policies +{ + public const string CanPurge = nameof(CanPurge); + public const string CanDelete = nameof(CanDelete); +} \ No newline at end of file diff --git a/src/Domain/Constants/Roles.cs b/src/Domain/Constants/Roles.cs new file mode 100644 index 0000000..38d9bf3 --- /dev/null +++ b/src/Domain/Constants/Roles.cs @@ -0,0 +1,6 @@ +namespace Hutopy.Domain.Constants; + +public abstract class Roles +{ + public const string Administrator = nameof(Administrator); +} \ No newline at end of file diff --git a/src/Domain/Domain.csproj b/src/Domain/Domain.csproj new file mode 100644 index 0000000..efab6e8 --- /dev/null +++ b/src/Domain/Domain.csproj @@ -0,0 +1,12 @@ + + + + Hutopy.Domain + Hutopy.Domain + + + + + + + diff --git a/src/Domain/Entities/TodoItem.cs b/src/Domain/Entities/TodoItem.cs new file mode 100644 index 0000000..e10f25b --- /dev/null +++ b/src/Domain/Entities/TodoItem.cs @@ -0,0 +1,31 @@ +namespace Hutopy.Domain.Entities; + +public class TodoItem : BaseAuditableEntity +{ + public int ListId { get; set; } + + public string? Title { get; set; } + + public string? Note { get; set; } + + public PriorityLevel Priority { get; set; } + + public DateTime? Reminder { get; set; } + + private bool _done; + public bool Done + { + get => _done; + set + { + if (value && !_done) + { + AddDomainEvent(new TodoItemCompletedEvent(this)); + } + + _done = value; + } + } + + public TodoList List { get; set; } = null!; +} diff --git a/src/Domain/Entities/TodoList.cs b/src/Domain/Entities/TodoList.cs new file mode 100644 index 0000000..365525e --- /dev/null +++ b/src/Domain/Entities/TodoList.cs @@ -0,0 +1,10 @@ +namespace Hutopy.Domain.Entities; + +public class TodoList : BaseAuditableEntity +{ + public string? Title { get; set; } + + public Colour Colour { get; set; } = Colour.White; + + public IList Items { get; private set; } = new List(); +} diff --git a/src/Domain/Enums/PriorityLevel.cs b/src/Domain/Enums/PriorityLevel.cs new file mode 100644 index 0000000..0a7ca16 --- /dev/null +++ b/src/Domain/Enums/PriorityLevel.cs @@ -0,0 +1,9 @@ +namespace Hutopy.Domain.Enums; + +public enum PriorityLevel +{ + None = 0, + Low = 1, + Medium = 2, + High = 3 +} diff --git a/src/Domain/Events/TodoItemCompletedEvent.cs b/src/Domain/Events/TodoItemCompletedEvent.cs new file mode 100644 index 0000000..b44c3ea --- /dev/null +++ b/src/Domain/Events/TodoItemCompletedEvent.cs @@ -0,0 +1,11 @@ +namespace Hutopy.Domain.Events; + +public class TodoItemCompletedEvent : BaseEvent +{ + public TodoItemCompletedEvent(TodoItem item) + { + Item = item; + } + + public TodoItem Item { get; } +} diff --git a/src/Domain/Events/TodoItemCreatedEvent.cs b/src/Domain/Events/TodoItemCreatedEvent.cs new file mode 100644 index 0000000..35db47f --- /dev/null +++ b/src/Domain/Events/TodoItemCreatedEvent.cs @@ -0,0 +1,11 @@ +namespace Hutopy.Domain.Events; + +public class TodoItemCreatedEvent : BaseEvent +{ + public TodoItemCreatedEvent(TodoItem item) + { + Item = item; + } + + public TodoItem Item { get; } +} diff --git a/src/Domain/Events/TodoItemDeletedEvent.cs b/src/Domain/Events/TodoItemDeletedEvent.cs new file mode 100644 index 0000000..d65711d --- /dev/null +++ b/src/Domain/Events/TodoItemDeletedEvent.cs @@ -0,0 +1,11 @@ +namespace Hutopy.Domain.Events; + +public class TodoItemDeletedEvent : BaseEvent +{ + public TodoItemDeletedEvent(TodoItem item) + { + Item = item; + } + + public TodoItem Item { get; } +} diff --git a/src/Domain/Exceptions/UnsupportedColourException.cs b/src/Domain/Exceptions/UnsupportedColourException.cs new file mode 100644 index 0000000..3946c28 --- /dev/null +++ b/src/Domain/Exceptions/UnsupportedColourException.cs @@ -0,0 +1,9 @@ +namespace Hutopy.Domain.Exceptions; + +public class UnsupportedColourException : Exception +{ + public UnsupportedColourException(string code) + : base($"Colour \"{code}\" is unsupported.") + { + } +} diff --git a/src/Domain/GlobalUsings.cs b/src/Domain/GlobalUsings.cs new file mode 100644 index 0000000..a15ae6d --- /dev/null +++ b/src/Domain/GlobalUsings.cs @@ -0,0 +1,6 @@ +global using Hutopy.Domain.Common; +global using Hutopy.Domain.Entities; +global using Hutopy.Domain.Enums; +global using Hutopy.Domain.Events; +global using Hutopy.Domain.Exceptions; +global using Hutopy.Domain.ValueObjects; \ No newline at end of file diff --git a/src/Domain/ValueObjects/Colour.cs b/src/Domain/ValueObjects/Colour.cs new file mode 100644 index 0000000..cf3d5ab --- /dev/null +++ b/src/Domain/ValueObjects/Colour.cs @@ -0,0 +1,69 @@ +namespace Hutopy.Domain.ValueObjects; + +public class Colour(string code) : ValueObject +{ + public static Colour From(string code) + { + var colour = new Colour(code); + + if (!SupportedColours.Contains(colour)) + { + throw new UnsupportedColourException(code); + } + + return colour; + } + + public static Colour White => new("#FFFFFF"); + + public static Colour Red => new("#FF5733"); + + public static Colour Orange => new("#FFC300"); + + public static Colour Yellow => new("#FFFF66"); + + public static Colour Green => new("#CCFF99"); + + public static Colour Blue => new("#6666FF"); + + public static Colour Purple => new("#9966CC"); + + public static Colour Grey => new("#999999"); + + public string Code { get; private set; } = string.IsNullOrWhiteSpace(code)?"#000000":code; + + public static implicit operator string(Colour colour) + { + return colour.ToString(); + } + + public static explicit operator Colour(string code) + { + return From(code); + } + + public override string ToString() + { + return Code; + } + + protected static IEnumerable SupportedColours + { + get + { + yield return White; + yield return Red; + yield return Orange; + yield return Yellow; + yield return Green; + yield return Blue; + yield return Purple; + yield return Grey; + } + } + + protected override IEnumerable GetEqualityComponents() + { + yield return Code; + } +} diff --git a/src/Infrastructure/Data/ApplicationDbContext.cs b/src/Infrastructure/Data/ApplicationDbContext.cs new file mode 100644 index 0000000..721bf1b --- /dev/null +++ b/src/Infrastructure/Data/ApplicationDbContext.cs @@ -0,0 +1,23 @@ +using System.Reflection; +using Hutopy.Application.Common.Interfaces; +using Hutopy.Domain.Entities; +using Hutopy.Infrastructure.Identity; +using Microsoft.AspNetCore.Identity.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore; + +namespace Hutopy.Infrastructure.Data; + +public class ApplicationDbContext : IdentityDbContext, IApplicationDbContext +{ + public ApplicationDbContext(DbContextOptions options) : base(options) { } + + public DbSet TodoLists => Set(); + + public DbSet TodoItems => Set(); + + protected override void OnModelCreating(ModelBuilder builder) + { + base.OnModelCreating(builder); + builder.ApplyConfigurationsFromAssembly(Assembly.GetExecutingAssembly()); + } +} diff --git a/src/Infrastructure/Data/ApplicationDbContextInitialiser.cs b/src/Infrastructure/Data/ApplicationDbContextInitialiser.cs new file mode 100644 index 0000000..eb7b336 --- /dev/null +++ b/src/Infrastructure/Data/ApplicationDbContextInitialiser.cs @@ -0,0 +1,109 @@ +using System.Runtime.InteropServices; +using Hutopy.Domain.Constants; +using Hutopy.Domain.Entities; +using Hutopy.Infrastructure.Identity; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Identity; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; + +namespace Hutopy.Infrastructure.Data; + +public static class InitialiserExtensions +{ + public static async Task InitialiseDatabaseAsync(this WebApplication app) + { + using var scope = app.Services.CreateScope(); + + var initialiser = scope.ServiceProvider.GetRequiredService(); + + await initialiser.InitialiseAsync(); + + await initialiser.SeedAsync(); + } +} + +public class ApplicationDbContextInitialiser +{ + private readonly ILogger _logger; + private readonly ApplicationDbContext _context; + private readonly UserManager _userManager; + private readonly RoleManager _roleManager; + + public ApplicationDbContextInitialiser(ILogger logger, ApplicationDbContext context, UserManager userManager, RoleManager roleManager) + { + _logger = logger; + _context = context; + _userManager = userManager; + _roleManager = roleManager; + } + + public async Task InitialiseAsync() + { + try + { + await _context.Database.MigrateAsync(); + } + catch (Exception ex) + { + _logger.LogError(ex, "An error occurred while initialising the database."); + throw; + } + } + + public async Task SeedAsync() + { + try + { + await TrySeedAsync(); + } + catch (Exception ex) + { + _logger.LogError(ex, "An error occurred while seeding the database."); + throw; + } + } + + public async Task TrySeedAsync() + { + // Default roles + var administratorRole = new IdentityRole(Roles.Administrator); + + if (_roleManager.Roles.All(r => r.Name != administratorRole.Name)) + { + await _roleManager.CreateAsync(administratorRole); + } + + // Default users + var administrator = new ApplicationUser { UserName = "administrator@localhost", Email = "administrator@localhost" }; + + if (_userManager.Users.All(u => u.UserName != administrator.UserName)) + { + await _userManager.CreateAsync(administrator, "Administrator1!"); + if (!string.IsNullOrWhiteSpace(administratorRole.Name)) + { + await _userManager.AddToRolesAsync(administrator, new [] { administratorRole.Name }); + } + } + + // Default data + // Seed, if necessary + if (!_context.TodoLists.Any()) + { + _context.TodoLists.Add(new TodoList + { + Title = "Todo List", + Items = + { + new TodoItem { Title = "Make a todo list 📃" }, + new TodoItem { Title = "Check off the first item ✅" }, + new TodoItem { Title = "Realise you've already done two things on the list! 🤯"}, + new TodoItem { Title = "Reward yourself with a nice, long nap 🏆" }, + } + }); + + await _context.SaveChangesAsync(); + } + } +} diff --git a/src/Infrastructure/Data/Configurations/TodoItemConfiguration.cs b/src/Infrastructure/Data/Configurations/TodoItemConfiguration.cs new file mode 100644 index 0000000..c6f9a2f --- /dev/null +++ b/src/Infrastructure/Data/Configurations/TodoItemConfiguration.cs @@ -0,0 +1,15 @@ +using Hutopy.Domain.Entities; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; + +namespace Hutopy.Infrastructure.Data.Configurations; + +public class TodoItemConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + builder.Property(t => t.Title) + .HasMaxLength(200) + .IsRequired(); + } +} diff --git a/src/Infrastructure/Data/Configurations/TodoListConfiguration.cs b/src/Infrastructure/Data/Configurations/TodoListConfiguration.cs new file mode 100644 index 0000000..3350621 --- /dev/null +++ b/src/Infrastructure/Data/Configurations/TodoListConfiguration.cs @@ -0,0 +1,18 @@ +using Hutopy.Domain.Entities; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; + +namespace Hutopy.Infrastructure.Data.Configurations; + +public class TodoListConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + builder.Property(t => t.Title) + .HasMaxLength(200) + .IsRequired(); + + builder + .OwnsOne(b => b.Colour); + } +} diff --git a/src/Infrastructure/Data/Interceptors/AuditableEntityInterceptor.cs b/src/Infrastructure/Data/Interceptors/AuditableEntityInterceptor.cs new file mode 100644 index 0000000..9d4b202 --- /dev/null +++ b/src/Infrastructure/Data/Interceptors/AuditableEntityInterceptor.cs @@ -0,0 +1,64 @@ +using Hutopy.Application.Common.Interfaces; +using Hutopy.Domain.Common; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.ChangeTracking; +using Microsoft.EntityFrameworkCore.Diagnostics; + +namespace Hutopy.Infrastructure.Data.Interceptors; + +public class AuditableEntityInterceptor : SaveChangesInterceptor +{ + private readonly IUser _user; + private readonly TimeProvider _dateTime; + + public AuditableEntityInterceptor( + IUser user, + TimeProvider dateTime) + { + _user = user; + _dateTime = dateTime; + } + + public override InterceptionResult SavingChanges(DbContextEventData eventData, InterceptionResult result) + { + UpdateEntities(eventData.Context); + + return base.SavingChanges(eventData, result); + } + + public override ValueTask> SavingChangesAsync(DbContextEventData eventData, InterceptionResult result, CancellationToken cancellationToken = default) + { + UpdateEntities(eventData.Context); + + return base.SavingChangesAsync(eventData, result, cancellationToken); + } + + public void UpdateEntities(DbContext? context) + { + if (context == null) return; + + foreach (var entry in context.ChangeTracker.Entries()) + { + if (entry.State is EntityState.Added or EntityState.Modified || entry.HasChangedOwnedEntities()) + { + var utcNow = _dateTime.GetUtcNow(); + if (entry.State == EntityState.Added) + { + entry.Entity.CreatedBy = _user.Id; + entry.Entity.Created = utcNow; + } + entry.Entity.LastModifiedBy = _user.Id; + entry.Entity.LastModified = utcNow; + } + } + } +} + +public static class Extensions +{ + public static bool HasChangedOwnedEntities(this EntityEntry entry) => + entry.References.Any(r => + r.TargetEntry != null && + r.TargetEntry.Metadata.IsOwned() && + (r.TargetEntry.State == EntityState.Added || r.TargetEntry.State == EntityState.Modified)); +} diff --git a/src/Infrastructure/Data/Interceptors/DispatchDomainEventsInterceptor.cs b/src/Infrastructure/Data/Interceptors/DispatchDomainEventsInterceptor.cs new file mode 100644 index 0000000..3664dd3 --- /dev/null +++ b/src/Infrastructure/Data/Interceptors/DispatchDomainEventsInterceptor.cs @@ -0,0 +1,50 @@ +using Hutopy.Domain.Common; +using MediatR; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Diagnostics; + +namespace Hutopy.Infrastructure.Data.Interceptors; + +public class DispatchDomainEventsInterceptor : SaveChangesInterceptor +{ + private readonly IMediator _mediator; + + public DispatchDomainEventsInterceptor(IMediator mediator) + { + _mediator = mediator; + } + + public override InterceptionResult SavingChanges(DbContextEventData eventData, InterceptionResult result) + { + DispatchDomainEvents(eventData.Context).GetAwaiter().GetResult(); + + return base.SavingChanges(eventData, result); + + } + + public override async ValueTask> SavingChangesAsync(DbContextEventData eventData, InterceptionResult result, CancellationToken cancellationToken = default) + { + await DispatchDomainEvents(eventData.Context); + + return await base.SavingChangesAsync(eventData, result, cancellationToken); + } + + public async Task DispatchDomainEvents(DbContext? context) + { + if (context == null) return; + + var entities = context.ChangeTracker + .Entries() + .Where(e => e.Entity.DomainEvents.Any()) + .Select(e => e.Entity); + + var domainEvents = entities + .SelectMany(e => e.DomainEvents) + .ToList(); + + entities.ToList().ForEach(e => e.ClearDomainEvents()); + + foreach (var domainEvent in domainEvents) + await _mediator.Publish(domainEvent); + } +} diff --git a/src/Infrastructure/Data/Migrations/00000000000000_InitialCreate.Designer.cs b/src/Infrastructure/Data/Migrations/00000000000000_InitialCreate.Designer.cs new file mode 100644 index 0000000..6b2f5af --- /dev/null +++ b/src/Infrastructure/Data/Migrations/00000000000000_InitialCreate.Designer.cs @@ -0,0 +1,399 @@ +// +using System; +using Hutopy.Infrastructure.Data; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Metadata; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; + +#nullable disable + +namespace Hutopy.Infrastructure.Data.Migrations +{ + [DbContext(typeof(ApplicationDbContext))] + [Migration("00000000000000_InitialCreate")] + partial class InitialCreate + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "8.0.0-preview.6.23329.4") + .HasAnnotation("Relational:MaxIdentifierLength", 128); + + SqlServerModelBuilderExtensions.UseIdentityColumns(modelBuilder); + + modelBuilder.Entity("Hutopy.Domain.Entities.TodoItem", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("Created") + .HasColumnType("datetimeoffset"); + + b.Property("CreatedBy") + .HasColumnType("nvarchar(max)"); + + b.Property("Done") + .HasColumnType("bit"); + + b.Property("LastModified") + .HasColumnType("datetimeoffset"); + + b.Property("LastModifiedBy") + .HasColumnType("nvarchar(max)"); + + b.Property("ListId") + .HasColumnType("int"); + + b.Property("Note") + .HasColumnType("nvarchar(max)"); + + b.Property("Priority") + .HasColumnType("int"); + + b.Property("Reminder") + .HasColumnType("datetime2"); + + b.Property("Title") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.HasKey("Id"); + + b.HasIndex("ListId"); + + b.ToTable("TodoItems"); + }); + + modelBuilder.Entity("Hutopy.Domain.Entities.TodoList", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("Created") + .HasColumnType("datetimeoffset"); + + b.Property("CreatedBy") + .HasColumnType("nvarchar(max)"); + + b.Property("LastModified") + .HasColumnType("datetimeoffset"); + + b.Property("LastModifiedBy") + .HasColumnType("nvarchar(max)"); + + b.Property("Title") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.HasKey("Id"); + + b.ToTable("TodoLists"); + }); + + modelBuilder.Entity("Hutopy.Infrastructure.Identity.ApplicationUser", b => + { + b.Property("Id") + .HasColumnType("nvarchar(450)"); + + b.Property("AccessFailedCount") + .HasColumnType("int"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .HasColumnType("nvarchar(max)"); + + b.Property("Email") + .HasMaxLength(256) + .HasColumnType("nvarchar(256)"); + + b.Property("EmailConfirmed") + .HasColumnType("bit"); + + b.Property("LockoutEnabled") + .HasColumnType("bit"); + + b.Property("LockoutEnd") + .HasColumnType("datetimeoffset"); + + b.Property("NormalizedEmail") + .HasMaxLength(256) + .HasColumnType("nvarchar(256)"); + + b.Property("NormalizedUserName") + .HasMaxLength(256) + .HasColumnType("nvarchar(256)"); + + b.Property("PasswordHash") + .HasColumnType("nvarchar(max)"); + + b.Property("PhoneNumber") + .HasColumnType("nvarchar(max)"); + + b.Property("PhoneNumberConfirmed") + .HasColumnType("bit"); + + b.Property("SecurityStamp") + .HasColumnType("nvarchar(max)"); + + b.Property("TwoFactorEnabled") + .HasColumnType("bit"); + + b.Property("UserName") + .HasMaxLength(256) + .HasColumnType("nvarchar(256)"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedEmail") + .HasDatabaseName("EmailIndex"); + + b.HasIndex("NormalizedUserName") + .IsUnique() + .HasDatabaseName("UserNameIndex") + .HasFilter("[NormalizedUserName] IS NOT NULL"); + + b.ToTable("AspNetUsers", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRole", b => + { + b.Property("Id") + .HasColumnType("nvarchar(450)"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .HasColumnType("nvarchar(max)"); + + b.Property("Name") + .HasMaxLength(256) + .HasColumnType("nvarchar(256)"); + + b.Property("NormalizedName") + .HasMaxLength(256) + .HasColumnType("nvarchar(256)"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedName") + .IsUnique() + .HasDatabaseName("RoleNameIndex") + .HasFilter("[NormalizedName] IS NOT NULL"); + + b.ToTable("AspNetRoles", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("ClaimType") + .HasColumnType("nvarchar(max)"); + + b.Property("ClaimValue") + .HasColumnType("nvarchar(max)"); + + b.Property("RoleId") + .IsRequired() + .HasColumnType("nvarchar(450)"); + + b.HasKey("Id"); + + b.HasIndex("RoleId"); + + b.ToTable("AspNetRoleClaims", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("ClaimType") + .HasColumnType("nvarchar(max)"); + + b.Property("ClaimValue") + .HasColumnType("nvarchar(max)"); + + b.Property("UserId") + .IsRequired() + .HasColumnType("nvarchar(450)"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.ToTable("AspNetUserClaims", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => + { + b.Property("LoginProvider") + .HasMaxLength(128) + .HasColumnType("nvarchar(128)"); + + b.Property("ProviderKey") + .HasMaxLength(128) + .HasColumnType("nvarchar(128)"); + + b.Property("ProviderDisplayName") + .HasColumnType("nvarchar(max)"); + + b.Property("UserId") + .IsRequired() + .HasColumnType("nvarchar(450)"); + + b.HasKey("LoginProvider", "ProviderKey"); + + b.HasIndex("UserId"); + + b.ToTable("AspNetUserLogins", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole", b => + { + b.Property("UserId") + .HasColumnType("nvarchar(450)"); + + b.Property("RoleId") + .HasColumnType("nvarchar(450)"); + + b.HasKey("UserId", "RoleId"); + + b.HasIndex("RoleId"); + + b.ToTable("AspNetUserRoles", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => + { + b.Property("UserId") + .HasColumnType("nvarchar(450)"); + + b.Property("LoginProvider") + .HasMaxLength(128) + .HasColumnType("nvarchar(128)"); + + b.Property("Name") + .HasMaxLength(128) + .HasColumnType("nvarchar(128)"); + + b.Property("Value") + .HasColumnType("nvarchar(max)"); + + b.HasKey("UserId", "LoginProvider", "Name"); + + b.ToTable("AspNetUserTokens", (string)null); + }); + + modelBuilder.Entity("Hutopy.Domain.Entities.TodoItem", b => + { + b.HasOne("Hutopy.Domain.Entities.TodoList", "List") + .WithMany("Items") + .HasForeignKey("ListId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("List"); + }); + + modelBuilder.Entity("Hutopy.Domain.Entities.TodoList", b => + { + b.OwnsOne("Hutopy.Domain.ValueObjects.Colour", "Colour", b1 => + { + b1.Property("TodoListId") + .HasColumnType("int"); + + b1.Property("Code") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b1.HasKey("TodoListId"); + + b1.ToTable("TodoLists"); + + b1.WithOwner() + .HasForeignKey("TodoListId"); + }); + + b.Navigation("Colour") + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => + { + b.HasOne("Microsoft.AspNetCore.Identity.IdentityRole", null) + .WithMany() + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => + { + b.HasOne("Hutopy.Infrastructure.Identity.ApplicationUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => + { + b.HasOne("Hutopy.Infrastructure.Identity.ApplicationUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole", b => + { + b.HasOne("Microsoft.AspNetCore.Identity.IdentityRole", null) + .WithMany() + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Hutopy.Infrastructure.Identity.ApplicationUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => + { + b.HasOne("Hutopy.Infrastructure.Identity.ApplicationUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Hutopy.Domain.Entities.TodoList", b => + { + b.Navigation("Items"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/src/Infrastructure/Data/Migrations/00000000000000_InitialCreate.cs b/src/Infrastructure/Data/Migrations/00000000000000_InitialCreate.cs new file mode 100644 index 0000000..3335275 --- /dev/null +++ b/src/Infrastructure/Data/Migrations/00000000000000_InitialCreate.cs @@ -0,0 +1,281 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace Hutopy.Infrastructure.Data.Migrations +{ + /// + public partial class InitialCreate : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.CreateTable( + name: "AspNetRoles", + columns: table => new + { + Id = table.Column(type: "nvarchar(450)", nullable: false), + Name = table.Column(type: "nvarchar(256)", maxLength: 256, nullable: true), + NormalizedName = table.Column(type: "nvarchar(256)", maxLength: 256, nullable: true), + ConcurrencyStamp = table.Column(type: "nvarchar(max)", nullable: true) + }, + constraints: table => + { + table.PrimaryKey("PK_AspNetRoles", x => x.Id); + }); + + migrationBuilder.CreateTable( + name: "AspNetUsers", + columns: table => new + { + Id = table.Column(type: "nvarchar(450)", nullable: false), + UserName = table.Column(type: "nvarchar(256)", maxLength: 256, nullable: true), + NormalizedUserName = table.Column(type: "nvarchar(256)", maxLength: 256, nullable: true), + Email = table.Column(type: "nvarchar(256)", maxLength: 256, nullable: true), + NormalizedEmail = table.Column(type: "nvarchar(256)", maxLength: 256, nullable: true), + EmailConfirmed = table.Column(type: "bit", nullable: false), + PasswordHash = table.Column(type: "nvarchar(max)", nullable: true), + SecurityStamp = table.Column(type: "nvarchar(max)", nullable: true), + ConcurrencyStamp = table.Column(type: "nvarchar(max)", nullable: true), + PhoneNumber = table.Column(type: "nvarchar(max)", nullable: true), + PhoneNumberConfirmed = table.Column(type: "bit", nullable: false), + TwoFactorEnabled = table.Column(type: "bit", nullable: false), + LockoutEnd = table.Column(type: "datetimeoffset", nullable: true), + LockoutEnabled = table.Column(type: "bit", nullable: false), + AccessFailedCount = table.Column(type: "int", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_AspNetUsers", x => x.Id); + }); + + migrationBuilder.CreateTable( + name: "TodoLists", + columns: table => new + { + Id = table.Column(type: "int", nullable: false) + .Annotation("SqlServer:Identity", "1, 1"), + Title = table.Column(type: "nvarchar(200)", maxLength: 200, nullable: false), + Colour_Code = table.Column(type: "nvarchar(max)", nullable: false), + Created = table.Column(type: "datetimeoffset", nullable: false), + CreatedBy = table.Column(type: "nvarchar(max)", nullable: true), + LastModified = table.Column(type: "datetimeoffset", nullable: false), + LastModifiedBy = table.Column(type: "nvarchar(max)", nullable: true) + }, + constraints: table => + { + table.PrimaryKey("PK_TodoLists", x => x.Id); + }); + + migrationBuilder.CreateTable( + name: "AspNetRoleClaims", + columns: table => new + { + Id = table.Column(type: "int", nullable: false) + .Annotation("SqlServer:Identity", "1, 1"), + RoleId = table.Column(type: "nvarchar(450)", nullable: false), + ClaimType = table.Column(type: "nvarchar(max)", nullable: true), + ClaimValue = table.Column(type: "nvarchar(max)", nullable: true) + }, + constraints: table => + { + table.PrimaryKey("PK_AspNetRoleClaims", x => x.Id); + table.ForeignKey( + name: "FK_AspNetRoleClaims_AspNetRoles_RoleId", + column: x => x.RoleId, + principalTable: "AspNetRoles", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateTable( + name: "AspNetUserClaims", + columns: table => new + { + Id = table.Column(type: "int", nullable: false) + .Annotation("SqlServer:Identity", "1, 1"), + UserId = table.Column(type: "nvarchar(450)", nullable: false), + ClaimType = table.Column(type: "nvarchar(max)", nullable: true), + ClaimValue = table.Column(type: "nvarchar(max)", nullable: true) + }, + constraints: table => + { + table.PrimaryKey("PK_AspNetUserClaims", x => x.Id); + table.ForeignKey( + name: "FK_AspNetUserClaims_AspNetUsers_UserId", + column: x => x.UserId, + principalTable: "AspNetUsers", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateTable( + name: "AspNetUserLogins", + columns: table => new + { + LoginProvider = table.Column(type: "nvarchar(128)", maxLength: 128, nullable: false), + ProviderKey = table.Column(type: "nvarchar(128)", maxLength: 128, nullable: false), + ProviderDisplayName = table.Column(type: "nvarchar(max)", nullable: true), + UserId = table.Column(type: "nvarchar(450)", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_AspNetUserLogins", x => new { x.LoginProvider, x.ProviderKey }); + table.ForeignKey( + name: "FK_AspNetUserLogins_AspNetUsers_UserId", + column: x => x.UserId, + principalTable: "AspNetUsers", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateTable( + name: "AspNetUserRoles", + columns: table => new + { + UserId = table.Column(type: "nvarchar(450)", nullable: false), + RoleId = table.Column(type: "nvarchar(450)", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_AspNetUserRoles", x => new { x.UserId, x.RoleId }); + table.ForeignKey( + name: "FK_AspNetUserRoles_AspNetRoles_RoleId", + column: x => x.RoleId, + principalTable: "AspNetRoles", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + table.ForeignKey( + name: "FK_AspNetUserRoles_AspNetUsers_UserId", + column: x => x.UserId, + principalTable: "AspNetUsers", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateTable( + name: "AspNetUserTokens", + columns: table => new + { + UserId = table.Column(type: "nvarchar(450)", nullable: false), + LoginProvider = table.Column(type: "nvarchar(128)", maxLength: 128, nullable: false), + Name = table.Column(type: "nvarchar(128)", maxLength: 128, nullable: false), + Value = table.Column(type: "nvarchar(max)", nullable: true) + }, + constraints: table => + { + table.PrimaryKey("PK_AspNetUserTokens", x => new { x.UserId, x.LoginProvider, x.Name }); + table.ForeignKey( + name: "FK_AspNetUserTokens_AspNetUsers_UserId", + column: x => x.UserId, + principalTable: "AspNetUsers", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateTable( + name: "TodoItems", + columns: table => new + { + Id = table.Column(type: "int", nullable: false) + .Annotation("SqlServer:Identity", "1, 1"), + ListId = table.Column(type: "int", nullable: false), + Title = table.Column(type: "nvarchar(200)", maxLength: 200, nullable: false), + Note = table.Column(type: "nvarchar(max)", nullable: true), + Priority = table.Column(type: "int", nullable: false), + Reminder = table.Column(type: "datetime2", nullable: true), + Done = table.Column(type: "bit", nullable: false), + Created = table.Column(type: "datetimeoffset", nullable: false), + CreatedBy = table.Column(type: "nvarchar(max)", nullable: true), + LastModified = table.Column(type: "datetimeoffset", nullable: false), + LastModifiedBy = table.Column(type: "nvarchar(max)", nullable: true) + }, + constraints: table => + { + table.PrimaryKey("PK_TodoItems", x => x.Id); + table.ForeignKey( + name: "FK_TodoItems_TodoLists_ListId", + column: x => x.ListId, + principalTable: "TodoLists", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateIndex( + name: "IX_AspNetRoleClaims_RoleId", + table: "AspNetRoleClaims", + column: "RoleId"); + + migrationBuilder.CreateIndex( + name: "RoleNameIndex", + table: "AspNetRoles", + column: "NormalizedName", + unique: true, + filter: "[NormalizedName] IS NOT NULL"); + + migrationBuilder.CreateIndex( + name: "IX_AspNetUserClaims_UserId", + table: "AspNetUserClaims", + column: "UserId"); + + migrationBuilder.CreateIndex( + name: "IX_AspNetUserLogins_UserId", + table: "AspNetUserLogins", + column: "UserId"); + + migrationBuilder.CreateIndex( + name: "IX_AspNetUserRoles_RoleId", + table: "AspNetUserRoles", + column: "RoleId"); + + migrationBuilder.CreateIndex( + name: "EmailIndex", + table: "AspNetUsers", + column: "NormalizedEmail"); + + migrationBuilder.CreateIndex( + name: "UserNameIndex", + table: "AspNetUsers", + column: "NormalizedUserName", + unique: true, + filter: "[NormalizedUserName] IS NOT NULL"); + + migrationBuilder.CreateIndex( + name: "IX_TodoItems_ListId", + table: "TodoItems", + column: "ListId"); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "AspNetRoleClaims"); + + migrationBuilder.DropTable( + name: "AspNetUserClaims"); + + migrationBuilder.DropTable( + name: "AspNetUserLogins"); + + migrationBuilder.DropTable( + name: "AspNetUserRoles"); + + migrationBuilder.DropTable( + name: "AspNetUserTokens"); + + migrationBuilder.DropTable( + name: "TodoItems"); + + migrationBuilder.DropTable( + name: "AspNetRoles"); + + migrationBuilder.DropTable( + name: "AspNetUsers"); + + migrationBuilder.DropTable( + name: "TodoLists"); + } + } +} diff --git a/src/Infrastructure/Data/Migrations/ApplicationDbContextModelSnapshot.cs b/src/Infrastructure/Data/Migrations/ApplicationDbContextModelSnapshot.cs new file mode 100644 index 0000000..7a9ffa0 --- /dev/null +++ b/src/Infrastructure/Data/Migrations/ApplicationDbContextModelSnapshot.cs @@ -0,0 +1,396 @@ +// +using System; +using Hutopy.Infrastructure.Data; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Metadata; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; + +#nullable disable + +namespace Hutopy.Infrastructure.Data.Migrations +{ + [DbContext(typeof(ApplicationDbContext))] + partial class ApplicationDbContextModelSnapshot : ModelSnapshot + { + protected override void BuildModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "8.0.0-preview.6.23329.4") + .HasAnnotation("Relational:MaxIdentifierLength", 128); + + SqlServerModelBuilderExtensions.UseIdentityColumns(modelBuilder); + + modelBuilder.Entity("Hutopy.Domain.Entities.TodoItem", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("Created") + .HasColumnType("datetimeoffset"); + + b.Property("CreatedBy") + .HasColumnType("nvarchar(max)"); + + b.Property("Done") + .HasColumnType("bit"); + + b.Property("LastModified") + .HasColumnType("datetimeoffset"); + + b.Property("LastModifiedBy") + .HasColumnType("nvarchar(max)"); + + b.Property("ListId") + .HasColumnType("int"); + + b.Property("Note") + .HasColumnType("nvarchar(max)"); + + b.Property("Priority") + .HasColumnType("int"); + + b.Property("Reminder") + .HasColumnType("datetime2"); + + b.Property("Title") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.HasKey("Id"); + + b.HasIndex("ListId"); + + b.ToTable("TodoItems"); + }); + + modelBuilder.Entity("Hutopy.Domain.Entities.TodoList", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("Created") + .HasColumnType("datetimeoffset"); + + b.Property("CreatedBy") + .HasColumnType("nvarchar(max)"); + + b.Property("LastModified") + .HasColumnType("datetimeoffset"); + + b.Property("LastModifiedBy") + .HasColumnType("nvarchar(max)"); + + b.Property("Title") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.HasKey("Id"); + + b.ToTable("TodoLists"); + }); + + modelBuilder.Entity("Hutopy.Infrastructure.Identity.ApplicationUser", b => + { + b.Property("Id") + .HasColumnType("nvarchar(450)"); + + b.Property("AccessFailedCount") + .HasColumnType("int"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .HasColumnType("nvarchar(max)"); + + b.Property("Email") + .HasMaxLength(256) + .HasColumnType("nvarchar(256)"); + + b.Property("EmailConfirmed") + .HasColumnType("bit"); + + b.Property("LockoutEnabled") + .HasColumnType("bit"); + + b.Property("LockoutEnd") + .HasColumnType("datetimeoffset"); + + b.Property("NormalizedEmail") + .HasMaxLength(256) + .HasColumnType("nvarchar(256)"); + + b.Property("NormalizedUserName") + .HasMaxLength(256) + .HasColumnType("nvarchar(256)"); + + b.Property("PasswordHash") + .HasColumnType("nvarchar(max)"); + + b.Property("PhoneNumber") + .HasColumnType("nvarchar(max)"); + + b.Property("PhoneNumberConfirmed") + .HasColumnType("bit"); + + b.Property("SecurityStamp") + .HasColumnType("nvarchar(max)"); + + b.Property("TwoFactorEnabled") + .HasColumnType("bit"); + + b.Property("UserName") + .HasMaxLength(256) + .HasColumnType("nvarchar(256)"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedEmail") + .HasDatabaseName("EmailIndex"); + + b.HasIndex("NormalizedUserName") + .IsUnique() + .HasDatabaseName("UserNameIndex") + .HasFilter("[NormalizedUserName] IS NOT NULL"); + + b.ToTable("AspNetUsers", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRole", b => + { + b.Property("Id") + .HasColumnType("nvarchar(450)"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .HasColumnType("nvarchar(max)"); + + b.Property("Name") + .HasMaxLength(256) + .HasColumnType("nvarchar(256)"); + + b.Property("NormalizedName") + .HasMaxLength(256) + .HasColumnType("nvarchar(256)"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedName") + .IsUnique() + .HasDatabaseName("RoleNameIndex") + .HasFilter("[NormalizedName] IS NOT NULL"); + + b.ToTable("AspNetRoles", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("ClaimType") + .HasColumnType("nvarchar(max)"); + + b.Property("ClaimValue") + .HasColumnType("nvarchar(max)"); + + b.Property("RoleId") + .IsRequired() + .HasColumnType("nvarchar(450)"); + + b.HasKey("Id"); + + b.HasIndex("RoleId"); + + b.ToTable("AspNetRoleClaims", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("ClaimType") + .HasColumnType("nvarchar(max)"); + + b.Property("ClaimValue") + .HasColumnType("nvarchar(max)"); + + b.Property("UserId") + .IsRequired() + .HasColumnType("nvarchar(450)"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.ToTable("AspNetUserClaims", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => + { + b.Property("LoginProvider") + .HasMaxLength(128) + .HasColumnType("nvarchar(128)"); + + b.Property("ProviderKey") + .HasMaxLength(128) + .HasColumnType("nvarchar(128)"); + + b.Property("ProviderDisplayName") + .HasColumnType("nvarchar(max)"); + + b.Property("UserId") + .IsRequired() + .HasColumnType("nvarchar(450)"); + + b.HasKey("LoginProvider", "ProviderKey"); + + b.HasIndex("UserId"); + + b.ToTable("AspNetUserLogins", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole", b => + { + b.Property("UserId") + .HasColumnType("nvarchar(450)"); + + b.Property("RoleId") + .HasColumnType("nvarchar(450)"); + + b.HasKey("UserId", "RoleId"); + + b.HasIndex("RoleId"); + + b.ToTable("AspNetUserRoles", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => + { + b.Property("UserId") + .HasColumnType("nvarchar(450)"); + + b.Property("LoginProvider") + .HasMaxLength(128) + .HasColumnType("nvarchar(128)"); + + b.Property("Name") + .HasMaxLength(128) + .HasColumnType("nvarchar(128)"); + + b.Property("Value") + .HasColumnType("nvarchar(max)"); + + b.HasKey("UserId", "LoginProvider", "Name"); + + b.ToTable("AspNetUserTokens", (string)null); + }); + + modelBuilder.Entity("Hutopy.Domain.Entities.TodoItem", b => + { + b.HasOne("Hutopy.Domain.Entities.TodoList", "List") + .WithMany("Items") + .HasForeignKey("ListId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("List"); + }); + + modelBuilder.Entity("Hutopy.Domain.Entities.TodoList", b => + { + b.OwnsOne("Hutopy.Domain.ValueObjects.Colour", "Colour", b1 => + { + b1.Property("TodoListId") + .HasColumnType("int"); + + b1.Property("Code") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b1.HasKey("TodoListId"); + + b1.ToTable("TodoLists"); + + b1.WithOwner() + .HasForeignKey("TodoListId"); + }); + + b.Navigation("Colour") + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => + { + b.HasOne("Microsoft.AspNetCore.Identity.IdentityRole", null) + .WithMany() + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => + { + b.HasOne("Hutopy.Infrastructure.Identity.ApplicationUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => + { + b.HasOne("Hutopy.Infrastructure.Identity.ApplicationUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole", b => + { + b.HasOne("Microsoft.AspNetCore.Identity.IdentityRole", null) + .WithMany() + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Hutopy.Infrastructure.Identity.ApplicationUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => + { + b.HasOne("Hutopy.Infrastructure.Identity.ApplicationUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Hutopy.Domain.Entities.TodoList", b => + { + b.Navigation("Items"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/src/Infrastructure/DependencyInjection.cs b/src/Infrastructure/DependencyInjection.cs new file mode 100644 index 0000000..3a2b7a8 --- /dev/null +++ b/src/Infrastructure/DependencyInjection.cs @@ -0,0 +1,58 @@ +using Hutopy.Application.Common.Interfaces; +using Hutopy.Domain.Constants; +using Hutopy.Infrastructure.Data; +using Hutopy.Infrastructure.Data.Interceptors; +using Hutopy.Infrastructure.Identity; +using Microsoft.AspNetCore.Identity; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Diagnostics; +using Microsoft.Extensions.Configuration; + +namespace Microsoft.Extensions.DependencyInjection; + +public static class DependencyInjection +{ + public static IServiceCollection AddInfrastructureServices(this IServiceCollection services, IConfiguration configuration) + { + // Replace password in the connection string with env var. + var connectionString = configuration.GetConnectionString("DefaultConnection") ?? ""; + var dbPassword = Environment.GetEnvironmentVariable("DB_PASSWORD"); + + connectionString = connectionString.Replace("{DB_PASSWORD}", dbPassword); + + Guard.Against.Null(connectionString, message: "Connection string 'DefaultConnection' not found."); + + services.AddScoped(); + services.AddScoped(); + + services.AddDbContext((sp, options) => + { + options.AddInterceptors(sp.GetServices()); + + options.UseSqlServer(connectionString); + }); + + services.AddScoped(provider => provider.GetRequiredService()); + + services.AddScoped(); + + services.AddAuthentication() + .AddBearerToken(IdentityConstants.BearerScheme); + + services.AddAuthorizationBuilder(); + + services + .AddIdentityCore() + .AddRoles() + .AddEntityFrameworkStores() + .AddApiEndpoints(); + + services.AddSingleton(TimeProvider.System); + services.AddTransient(); + + services.AddAuthorization(options => + options.AddPolicy(Policies.CanPurge, policy => policy.RequireRole(Roles.Administrator))); + + return services; + } +} diff --git a/src/Infrastructure/GlobalUsings.cs b/src/Infrastructure/GlobalUsings.cs new file mode 100644 index 0000000..4668da2 --- /dev/null +++ b/src/Infrastructure/GlobalUsings.cs @@ -0,0 +1 @@ +global using Ardalis.GuardClauses; \ No newline at end of file diff --git a/src/Infrastructure/Identity/ApplicationUser.cs b/src/Infrastructure/Identity/ApplicationUser.cs new file mode 100644 index 0000000..badd6ee --- /dev/null +++ b/src/Infrastructure/Identity/ApplicationUser.cs @@ -0,0 +1,7 @@ +using Microsoft.AspNetCore.Identity; + +namespace Hutopy.Infrastructure.Identity; + +public class ApplicationUser : IdentityUser +{ +} diff --git a/src/Infrastructure/Identity/IdentityResultExtensions.cs b/src/Infrastructure/Identity/IdentityResultExtensions.cs new file mode 100644 index 0000000..630c372 --- /dev/null +++ b/src/Infrastructure/Identity/IdentityResultExtensions.cs @@ -0,0 +1,14 @@ +using Hutopy.Application.Common.Models; +using Microsoft.AspNetCore.Identity; + +namespace Hutopy.Infrastructure.Identity; + +public static class IdentityResultExtensions +{ + public static Result ToApplicationResult(this IdentityResult result) + { + return result.Succeeded + ? Result.Success() + : Result.Failure(result.Errors.Select(e => e.Description)); + } +} diff --git a/src/Infrastructure/Identity/IdentityService.cs b/src/Infrastructure/Identity/IdentityService.cs new file mode 100644 index 0000000..015100b --- /dev/null +++ b/src/Infrastructure/Identity/IdentityService.cs @@ -0,0 +1,80 @@ +using Hutopy.Application.Common.Interfaces; +using Hutopy.Application.Common.Models; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Identity; + +namespace Hutopy.Infrastructure.Identity; + +public class IdentityService : IIdentityService +{ + private readonly UserManager _userManager; + private readonly IUserClaimsPrincipalFactory _userClaimsPrincipalFactory; + private readonly IAuthorizationService _authorizationService; + + public IdentityService( + UserManager userManager, + IUserClaimsPrincipalFactory userClaimsPrincipalFactory, + IAuthorizationService authorizationService) + { + _userManager = userManager; + _userClaimsPrincipalFactory = userClaimsPrincipalFactory; + _authorizationService = authorizationService; + } + + public async Task GetUserNameAsync(string userId) + { + var user = await _userManager.FindByIdAsync(userId); + + return user?.UserName; + } + + public async Task<(Result Result, string UserId)> CreateUserAsync(string userName, string password) + { + var user = new ApplicationUser + { + UserName = userName, + Email = userName, + }; + + var result = await _userManager.CreateAsync(user, password); + + return (result.ToApplicationResult(), user.Id); + } + + public async Task IsInRoleAsync(string userId, string role) + { + var user = await _userManager.FindByIdAsync(userId); + + return user != null && await _userManager.IsInRoleAsync(user, role); + } + + public async Task AuthorizeAsync(string userId, string policyName) + { + var user = await _userManager.FindByIdAsync(userId); + + if (user == null) + { + return false; + } + + var principal = await _userClaimsPrincipalFactory.CreateAsync(user); + + var result = await _authorizationService.AuthorizeAsync(principal, policyName); + + return result.Succeeded; + } + + public async Task DeleteUserAsync(string userId) + { + var user = await _userManager.FindByIdAsync(userId); + + return user != null ? await DeleteUserAsync(user) : Result.Success(); + } + + public async Task DeleteUserAsync(ApplicationUser user) + { + var result = await _userManager.DeleteAsync(user); + + return result.ToApplicationResult(); + } +} diff --git a/src/Infrastructure/Infrastructure.csproj b/src/Infrastructure/Infrastructure.csproj new file mode 100644 index 0000000..f68f7c8 --- /dev/null +++ b/src/Infrastructure/Infrastructure.csproj @@ -0,0 +1,20 @@ + + + + Hutopy.Infrastructure + Hutopy.Infrastructure + + + + + + + + + + + + + + + diff --git a/src/Web/DependencyInjection.cs b/src/Web/DependencyInjection.cs new file mode 100644 index 0000000..a7dbc86 --- /dev/null +++ b/src/Web/DependencyInjection.cs @@ -0,0 +1,66 @@ +using Azure.Identity; +using Hutopy.Application.Common.Interfaces; +using Hutopy.Infrastructure.Data; +using Hutopy.Web.Services; +using Microsoft.AspNetCore.Mvc; + +using NSwag; +using NSwag.Generation.Processors.Security; + +namespace Microsoft.Extensions.DependencyInjection; + +public static class DependencyInjection +{ + public static IServiceCollection AddWebServices(this IServiceCollection services) + { + services.AddDatabaseDeveloperPageExceptionFilter(); + + services.AddScoped(); + + services.AddHttpContextAccessor(); + + services.AddHealthChecks() + .AddDbContextCheck(); + + services.AddExceptionHandler(); + + services.AddRazorPages(); + + // Customise default API behaviour + services.Configure(options => + options.SuppressModelStateInvalidFilter = true); + + services.AddEndpointsApiExplorer(); + + services.AddOpenApiDocument((configure, sp) => + { + configure.Title = "Hutopy API"; + + // Add JWT + configure.AddSecurity("JWT", Enumerable.Empty(), new OpenApiSecurityScheme + { + Type = OpenApiSecuritySchemeType.ApiKey, + Name = "Authorization", + In = OpenApiSecurityApiKeyLocation.Header, + Description = "Type into the textbox: Bearer {your JWT token}." + }); + + configure.OperationProcessors.Add(new AspNetCoreOperationSecurityScopeProcessor("JWT")); + }); + + return services; + } + + public static IServiceCollection AddKeyVaultIfConfigured(this IServiceCollection services, ConfigurationManager configuration) + { + var keyVaultUri = configuration["KeyVaultUri"]; + if (!string.IsNullOrWhiteSpace(keyVaultUri)) + { + configuration.AddAzureKeyVault( + new Uri(keyVaultUri), + new DefaultAzureCredential()); + } + + return services; + } +} diff --git a/src/Web/Endpoints/TodoItems.cs b/src/Web/Endpoints/TodoItems.cs new file mode 100644 index 0000000..7cb1fa4 --- /dev/null +++ b/src/Web/Endpoints/TodoItems.cs @@ -0,0 +1,52 @@ +using Hutopy.Application.Common.Models; +using Hutopy.Application.TodoItems.Commands.CreateTodoItem; +using Hutopy.Application.TodoItems.Commands.DeleteTodoItem; +using Hutopy.Application.TodoItems.Commands.UpdateTodoItem; +using Hutopy.Application.TodoItems.Commands.UpdateTodoItemDetail; +using Hutopy.Application.TodoItems.Queries.GetTodoItemsWithPagination; + +namespace Hutopy.Web.Endpoints; + +public class TodoItems : EndpointGroupBase +{ + public override void Map(WebApplication app) + { + app.MapGroup(this) + .RequireAuthorization() + .MapGet(GetTodoItemsWithPagination) + .MapPost(CreateTodoItem) + .MapPut(UpdateTodoItem, "{id}") + .MapPut(UpdateTodoItemDetail, "UpdateDetail/{id}") + .MapDelete(DeleteTodoItem, "{id}"); + } + + public Task> GetTodoItemsWithPagination(ISender sender, [AsParameters] GetTodoItemsWithPaginationQuery query) + { + return sender.Send(query); + } + + public Task CreateTodoItem(ISender sender, CreateTodoItemCommand command) + { + return sender.Send(command); + } + + public async Task UpdateTodoItem(ISender sender, int id, UpdateTodoItemCommand command) + { + if (id != command.Id) return Results.BadRequest(); + await sender.Send(command); + return Results.NoContent(); + } + + public async Task UpdateTodoItemDetail(ISender sender, int id, UpdateTodoItemDetailCommand command) + { + if (id != command.Id) return Results.BadRequest(); + await sender.Send(command); + return Results.NoContent(); + } + + public async Task DeleteTodoItem(ISender sender, int id) + { + await sender.Send(new DeleteTodoItemCommand(id)); + return Results.NoContent(); + } +} diff --git a/src/Web/Endpoints/TodoLists.cs b/src/Web/Endpoints/TodoLists.cs new file mode 100644 index 0000000..fafd341 --- /dev/null +++ b/src/Web/Endpoints/TodoLists.cs @@ -0,0 +1,42 @@ +using Hutopy.Application.TodoLists.Commands.CreateTodoList; +using Hutopy.Application.TodoLists.Commands.DeleteTodoList; +using Hutopy.Application.TodoLists.Commands.UpdateTodoList; +using Hutopy.Application.TodoLists.Queries.GetTodos; + +namespace Hutopy.Web.Endpoints; + +public class TodoLists : EndpointGroupBase +{ + public override void Map(WebApplication app) + { + app.MapGroup(this) + .RequireAuthorization() + .MapGet(GetTodoLists) + .MapPost(CreateTodoList) + .MapPut(UpdateTodoList, "{id}") + .MapDelete(DeleteTodoList, "{id}"); + } + + public Task GetTodoLists(ISender sender) + { + return sender.Send(new GetTodosQuery()); + } + + public Task CreateTodoList(ISender sender, CreateTodoListCommand command) + { + return sender.Send(command); + } + + public async Task UpdateTodoList(ISender sender, int id, UpdateTodoListCommand command) + { + if (id != command.Id) return Results.BadRequest(); + await sender.Send(command); + return Results.NoContent(); + } + + public async Task DeleteTodoList(ISender sender, int id) + { + await sender.Send(new DeleteTodoListCommand(id)); + return Results.NoContent(); + } +} diff --git a/src/Web/Endpoints/Users.cs b/src/Web/Endpoints/Users.cs new file mode 100644 index 0000000..c27114b --- /dev/null +++ b/src/Web/Endpoints/Users.cs @@ -0,0 +1,12 @@ +using Hutopy.Infrastructure.Identity; + +namespace Hutopy.Web.Endpoints; + +public class Users : EndpointGroupBase +{ + public override void Map(WebApplication app) + { + app.MapGroup(this) + .MapIdentityApi(); + } +} diff --git a/src/Web/Endpoints/WeatherForecasts.cs b/src/Web/Endpoints/WeatherForecasts.cs new file mode 100644 index 0000000..8eef1fd --- /dev/null +++ b/src/Web/Endpoints/WeatherForecasts.cs @@ -0,0 +1,18 @@ +using Hutopy.Application.WeatherForecasts.Queries.GetWeatherForecasts; + +namespace Hutopy.Web.Endpoints; + +public class WeatherForecasts : EndpointGroupBase +{ + public override void Map(WebApplication app) + { + app.MapGroup(this) + .RequireAuthorization() + .MapGet(GetWeatherForecasts); + } + + public async Task> GetWeatherForecasts(ISender sender) + { + return await sender.Send(new GetWeatherForecastsQuery()); + } +} diff --git a/src/Web/GlobalUsings.cs b/src/Web/GlobalUsings.cs new file mode 100644 index 0000000..a69805b --- /dev/null +++ b/src/Web/GlobalUsings.cs @@ -0,0 +1,3 @@ +global using Ardalis.GuardClauses; +global using Hutopy.Web.Infrastructure; +global using MediatR; diff --git a/src/Web/Infrastructure/CustomExceptionHandler.cs b/src/Web/Infrastructure/CustomExceptionHandler.cs new file mode 100644 index 0000000..e49063b --- /dev/null +++ b/src/Web/Infrastructure/CustomExceptionHandler.cs @@ -0,0 +1,87 @@ +using Hutopy.Application.Common.Exceptions; +using Microsoft.AspNetCore.Diagnostics; +using Microsoft.AspNetCore.Mvc; + +namespace Hutopy.Web.Infrastructure; + +public class CustomExceptionHandler : IExceptionHandler +{ + private readonly Dictionary> _exceptionHandlers; + + public CustomExceptionHandler() + { + // Register known exception types and handlers. + _exceptionHandlers = new() + { + { typeof(ValidationException), HandleValidationException }, + { typeof(NotFoundException), HandleNotFoundException }, + { typeof(UnauthorizedAccessException), HandleUnauthorizedAccessException }, + { typeof(ForbiddenAccessException), HandleForbiddenAccessException }, + }; + } + + public async ValueTask TryHandleAsync(HttpContext httpContext, Exception exception, CancellationToken cancellationToken) + { + var exceptionType = exception.GetType(); + + if (_exceptionHandlers.ContainsKey(exceptionType)) + { + await _exceptionHandlers[exceptionType].Invoke(httpContext, exception); + return true; + } + + return false; + } + + private async Task HandleValidationException(HttpContext httpContext, Exception ex) + { + var exception = (ValidationException)ex; + + httpContext.Response.StatusCode = StatusCodes.Status400BadRequest; + + await httpContext.Response.WriteAsJsonAsync(new ValidationProblemDetails(exception.Errors) + { + Status = StatusCodes.Status400BadRequest, + Type = "https://tools.ietf.org/html/rfc7231#section-6.5.1" + }); + } + + private async Task HandleNotFoundException(HttpContext httpContext, Exception ex) + { + var exception = (NotFoundException)ex; + + httpContext.Response.StatusCode = StatusCodes.Status404NotFound; + + await httpContext.Response.WriteAsJsonAsync(new ProblemDetails() + { + Status = StatusCodes.Status404NotFound, + Type = "https://tools.ietf.org/html/rfc7231#section-6.5.4", + Title = "The specified resource was not found.", + Detail = exception.Message + }); + } + + private async Task HandleUnauthorizedAccessException(HttpContext httpContext, Exception ex) + { + httpContext.Response.StatusCode = StatusCodes.Status401Unauthorized; + + await httpContext.Response.WriteAsJsonAsync(new ProblemDetails + { + Status = StatusCodes.Status401Unauthorized, + Title = "Unauthorized", + Type = "https://tools.ietf.org/html/rfc7235#section-3.1" + }); + } + + private async Task HandleForbiddenAccessException(HttpContext httpContext, Exception ex) + { + httpContext.Response.StatusCode = StatusCodes.Status403Forbidden; + + await httpContext.Response.WriteAsJsonAsync(new ProblemDetails + { + Status = StatusCodes.Status403Forbidden, + Title = "Forbidden", + Type = "https://tools.ietf.org/html/rfc7231#section-6.5.3" + }); + } +} diff --git a/src/Web/Infrastructure/EndpointGroupBase.cs b/src/Web/Infrastructure/EndpointGroupBase.cs new file mode 100644 index 0000000..00b4ca7 --- /dev/null +++ b/src/Web/Infrastructure/EndpointGroupBase.cs @@ -0,0 +1,6 @@ +namespace Hutopy.Web.Infrastructure; + +public abstract class EndpointGroupBase +{ + public abstract void Map(WebApplication app); +} diff --git a/src/Web/Infrastructure/IEndpointRouteBuilderExtensions.cs b/src/Web/Infrastructure/IEndpointRouteBuilderExtensions.cs new file mode 100644 index 0000000..08badbc --- /dev/null +++ b/src/Web/Infrastructure/IEndpointRouteBuilderExtensions.cs @@ -0,0 +1,46 @@ +using System.Diagnostics.CodeAnalysis; + +namespace Hutopy.Web.Infrastructure; + +public static class IEndpointRouteBuilderExtensions +{ + public static IEndpointRouteBuilder MapGet(this IEndpointRouteBuilder builder, Delegate handler, [StringSyntax("Route")] string pattern = "") + { + Guard.Against.AnonymousMethod(handler); + + builder.MapGet(pattern, handler) + .WithName(handler.Method.Name); + + return builder; + } + + public static IEndpointRouteBuilder MapPost(this IEndpointRouteBuilder builder, Delegate handler, [StringSyntax("Route")] string pattern = "") + { + Guard.Against.AnonymousMethod(handler); + + builder.MapPost(pattern, handler) + .WithName(handler.Method.Name); + + return builder; + } + + public static IEndpointRouteBuilder MapPut(this IEndpointRouteBuilder builder, Delegate handler, [StringSyntax("Route")] string pattern) + { + Guard.Against.AnonymousMethod(handler); + + builder.MapPut(pattern, handler) + .WithName(handler.Method.Name); + + return builder; + } + + public static IEndpointRouteBuilder MapDelete(this IEndpointRouteBuilder builder, Delegate handler, [StringSyntax("Route")] string pattern) + { + Guard.Against.AnonymousMethod(handler); + + builder.MapDelete(pattern, handler) + .WithName(handler.Method.Name); + + return builder; + } +} diff --git a/src/Web/Infrastructure/MethodInfoExtensions.cs b/src/Web/Infrastructure/MethodInfoExtensions.cs new file mode 100644 index 0000000..d526cb4 --- /dev/null +++ b/src/Web/Infrastructure/MethodInfoExtensions.cs @@ -0,0 +1,18 @@ +using System.Reflection; + +namespace Hutopy.Web.Infrastructure; + +public static class MethodInfoExtensions +{ + public static bool IsAnonymous(this MethodInfo method) + { + var invalidChars = new[] { '<', '>' }; + return method.Name.Any(invalidChars.Contains); + } + + public static void AnonymousMethod(this IGuardClause guardClause, Delegate input) + { + if (input.Method.IsAnonymous()) + throw new ArgumentException("The endpoint name must be specified when using anonymous handlers."); + } +} \ No newline at end of file diff --git a/src/Web/Infrastructure/WebApplicationExtensions.cs b/src/Web/Infrastructure/WebApplicationExtensions.cs new file mode 100644 index 0000000..7d977fc --- /dev/null +++ b/src/Web/Infrastructure/WebApplicationExtensions.cs @@ -0,0 +1,37 @@ +using System.Reflection; + +namespace Hutopy.Web.Infrastructure; + +public static class WebApplicationExtensions +{ + public static RouteGroupBuilder MapGroup(this WebApplication app, EndpointGroupBase group) + { + var groupName = group.GetType().Name; + + return app + .MapGroup($"/api/{groupName}") + .WithGroupName(groupName) + .WithTags(groupName) + .WithOpenApi(); + } + + public static WebApplication MapEndpoints(this WebApplication app) + { + var endpointGroupType = typeof(EndpointGroupBase); + + var assembly = Assembly.GetExecutingAssembly(); + + var endpointGroupTypes = assembly.GetExportedTypes() + .Where(t => t.IsSubclassOf(endpointGroupType)); + + foreach (var type in endpointGroupTypes) + { + if (Activator.CreateInstance(type) is EndpointGroupBase instance) + { + instance.Map(app); + } + } + + return app; + } +} diff --git a/src/Web/Pages/Error.cshtml b/src/Web/Pages/Error.cshtml new file mode 100644 index 0000000..6f92b95 --- /dev/null +++ b/src/Web/Pages/Error.cshtml @@ -0,0 +1,26 @@ +@page +@model ErrorModel +@{ + ViewData["Title"] = "Error"; +} + +

Error.

+

An error occurred while processing your request.

+ +@if (Model.ShowRequestId) +{ +

+ Request ID: @Model.RequestId +

+} + +

Development Mode

+

+ Swapping to the Development environment displays detailed information about the error that occurred. +

+

+ The Development environment shouldn't be enabled for deployed applications. + It can result in displaying sensitive information from exceptions to end users. + For local debugging, enable the Development environment by setting the ASPNETCORE_ENVIRONMENT environment variable to Development + and restarting the app. +

diff --git a/src/Web/Pages/Error.cshtml.cs b/src/Web/Pages/Error.cshtml.cs new file mode 100644 index 0000000..ff5eba0 --- /dev/null +++ b/src/Web/Pages/Error.cshtml.cs @@ -0,0 +1,25 @@ +using System.Diagnostics; +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Mvc.RazorPages; + +namespace Hutopy.Web.Pages; + +[ResponseCache(Duration = 0, Location = ResponseCacheLocation.None, NoStore = true)] +public class ErrorModel : PageModel +{ + private readonly ILogger _logger; + + public ErrorModel(ILogger logger) + { + _logger = logger; + } + + public string? RequestId { get; set; } + + public bool ShowRequestId => !string.IsNullOrEmpty(RequestId); + + public void OnGet() + { + RequestId = Activity.Current?.Id ?? HttpContext.TraceIdentifier; + } +} diff --git a/src/Web/Pages/Shared/_LoginPartial.cshtml b/src/Web/Pages/Shared/_LoginPartial.cshtml new file mode 100644 index 0000000..c16ebd7 --- /dev/null +++ b/src/Web/Pages/Shared/_LoginPartial.cshtml @@ -0,0 +1,36 @@ +@using Hutopy.Infrastructure.Identity +@using Microsoft.AspNetCore.Identity +@inject SignInManager SignInManager +@inject UserManager UserManager + +@{ + string? returnUrl = null; + var query = ViewContext.HttpContext.Request.Query; + if (query.ContainsKey("returnUrl")) + { + returnUrl = query["returnUrl"]; + } +} + + diff --git a/src/Web/Pages/_ViewImports.cshtml b/src/Web/Pages/_ViewImports.cshtml new file mode 100644 index 0000000..315c2e2 --- /dev/null +++ b/src/Web/Pages/_ViewImports.cshtml @@ -0,0 +1,3 @@ +@using Hutopy.Web +@namespace Hutopy.Web.Pages +@addTagHelper *, Microsoft.AspNetCore.Mvc.TagHelpers diff --git a/src/Web/Program.cs b/src/Web/Program.cs new file mode 100644 index 0000000..89e64ee --- /dev/null +++ b/src/Web/Program.cs @@ -0,0 +1,64 @@ +using Hutopy.Infrastructure.Data; + +var builder = WebApplication.CreateBuilder(args); + +builder.Services.AddCors(options => + { + options.AddPolicy("AllowAll", builder => + { + builder.AllowAnyOrigin() + .AllowAnyMethod() + .AllowAnyHeader(); + }); + }); + +// Add services to the container. +builder.Services.AddKeyVaultIfConfigured(builder.Configuration); + +builder.Services.AddApplicationServices(); +builder.Services.AddInfrastructureServices(builder.Configuration); +builder.Services.AddWebServices(); + + +var app = builder.Build(); + +app.UseCors("AllowAll"); + +// Configure the HTTP request pipeline. +if (app.Environment.IsDevelopment()) +{ + await app.InitialiseDatabaseAsync(); +} +else +{ + // The default HSTS value is 30 days. You may want to change this for production scenarios, see https://aka.ms/aspnetcore-hsts. + app.UseHsts(); +} + +app.UseHealthChecks("/health"); +app.UseHttpsRedirection(); +app.UseStaticFiles(); + +app.UseSwaggerUi(settings => +{ + settings.Path = "/api"; + settings.DocumentPath = "/api/specification.json"; +}); + +app.MapControllerRoute( + name: "default", + pattern: "{controller}/{action=Index}/{id?}"); + +app.MapRazorPages(); + +app.MapFallbackToFile("index.html"); + +app.UseExceptionHandler(options => { }); + +app.Map("/", () => Results.Redirect("/api")); + +app.MapEndpoints(); + +app.Run(); + +public partial class Program { } diff --git a/src/Web/Properties/launchSettings.json b/src/Web/Properties/launchSettings.json new file mode 100644 index 0000000..6ef4f83 --- /dev/null +++ b/src/Web/Properties/launchSettings.json @@ -0,0 +1,27 @@ +{ + "iisSettings": { + "windowsAuthentication": false, + "anonymousAuthentication": true, + "iisExpress": { + "applicationUrl": "http://localhost:61846", + "sslPort": 44312 + } + }, + "profiles": { + "Hutopy.Web": { + "commandName": "Project", + "launchBrowser": true, + "applicationUrl": "https://localhost:5001;http://localhost:5000", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + }, + "IIS Express": { + "commandName": "IISExpress", + "launchBrowser": true, + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + } + } +} \ No newline at end of file diff --git a/src/Web/Services/CurrentUser.cs b/src/Web/Services/CurrentUser.cs new file mode 100644 index 0000000..45d3365 --- /dev/null +++ b/src/Web/Services/CurrentUser.cs @@ -0,0 +1,17 @@ +using System.Security.Claims; + +using Hutopy.Application.Common.Interfaces; + +namespace Hutopy.Web.Services; + +public class CurrentUser : IUser +{ + private readonly IHttpContextAccessor _httpContextAccessor; + + public CurrentUser(IHttpContextAccessor httpContextAccessor) + { + _httpContextAccessor = httpContextAccessor; + } + + public string? Id => _httpContextAccessor.HttpContext?.User?.FindFirstValue(ClaimTypes.NameIdentifier); +} diff --git a/src/Web/Web.csproj b/src/Web/Web.csproj new file mode 100644 index 0000000..27c2d2d --- /dev/null +++ b/src/Web/Web.csproj @@ -0,0 +1,44 @@ + + + + Hutopy.Web + Hutopy.Web + + + + + + + + + + + + + + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + + + + + + OnBuildSuccess + + + + + + + + + + + + + diff --git a/src/Web/Web.http b/src/Web/Web.http new file mode 100644 index 0000000..105a254 --- /dev/null +++ b/src/Web/Web.http @@ -0,0 +1,139 @@ +# For more info on HTTP files go to https://aka.ms/vs/httpfile +@Web_HostAddress = https://localhost:5001 + +@Email=administrator@localhost +@Password=Administrator1! +@BearerToken= + +# POST Users Register +POST {{Web_HostAddress}}/api/Users/Register +Content-Type: application/json + +{ + "email": "{{Email}}", + "password": "{{Password}}" +} + +### + +# POST Users Login +POST {{Web_HostAddress}}/api/Users/Login +Content-Type: application/json + +{ + "email": "{{Email}}", + "password": "{{Password}}" +} + +### + +# POST Users Refresh +POST {{Web_HostAddress}}/api/Users/Refresh +Authorization: Bearer {{BearerToken}} +Content-Type: application/json + +{ + "refreshToken": "" +} + +### + +# GET WeatherForecast +GET {{Web_HostAddress}}/api/WeatherForecasts +Authorization: Bearer {{BearerToken}} + +### + +# GET TodoLists +GET {{Web_HostAddress}}/api/TodoLists +Authorization: Bearer {{BearerToken}} + +### + +# POST TodoLists +POST {{Web_HostAddress}}/api/TodoLists +Authorization: Bearer {{BearerToken}} +Content-Type: application/json + +// CreateTodoListCommand +{ + "Title": "Backlog" +} + +### + +# PUT TodoLists +PUT {{Web_HostAddress}}/api/TodoLists/1 +Authorization: Bearer {{BearerToken}} +Content-Type: application/json + +// UpdateTodoListCommand +{ + "Id": 1, + "Title": "Product Backlog" +} + +### + +# DELETE TodoLists +DELETE {{Web_HostAddress}}/api/TodoLists/1 +Authorization: Bearer {{BearerToken}} + +### + +# GET TodoItems +@PageNumber = 1 +@PageSize = 10 +GET {{Web_HostAddress}}/api/TodoItems?ListId=1&PageNumber={{PageNumber}}&PageSize={{PageSize}} + +Authorization: Bearer {{BearerToken}} + +### + +# POST TodoItems +POST {{Web_HostAddress}}/api/TodoItems +Authorization: Bearer {{BearerToken}} +Content-Type: application/json + +// CreateTodoItemCommand +{ + "ListId": 1, + "Title": "Eat a burrito 🌯" +} + +### + +#PUT TodoItems UpdateItemDetails +PUT {{Web_HostAddress}}/api/TodoItems/UpdateItemDetails?Id=1 +Authorization: Bearer {{BearerToken}} +Content-Type: application/json + +// UpdateTodoItemDetailCommand +{ + "Id": 1, + "ListId": 1, + "Priority": 3, + "Note": "This is a good idea!" +} + +### + +# PUT TodoItems +PUT {{Web_HostAddress}}/api/TodoItems/1 +Authorization: Bearer {{BearerToken}} +Content-Type: application/json + +// UpdateTodoItemCommand +{ + "Id": 1, + "Title": "Eat a yummy burrito 🌯", + "Done": true +} + +### + +# DELETE TodoItem +DELETE {{Web_HostAddress}}/api/TodoItems/1 +Authorization: Bearer {{BearerToken}} + +### \ No newline at end of file diff --git a/src/Web/appsettings.Development.json b/src/Web/appsettings.Development.json new file mode 100644 index 0000000..84308c9 --- /dev/null +++ b/src/Web/appsettings.Development.json @@ -0,0 +1,10 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft": "Warning", + "Microsoft.AspNetCore.SpaProxy": "Information", + "Microsoft.Hosting.Lifetime": "Information" + } + } +} diff --git a/src/Web/appsettings.json b/src/Web/appsettings.json new file mode 100644 index 0000000..bedce59 --- /dev/null +++ b/src/Web/appsettings.json @@ -0,0 +1,13 @@ +{ + "ConnectionStrings": { + "DefaultConnection": "Server=localhost,1433;Database=TestDeux;User Id=sa;Password={DB_PASSWORD};MultipleActiveResultSets=true;TrustServerCertificate=True" + }, + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft": "Warning", + "Microsoft.Hosting.Lifetime": "Information" + } + }, + "AllowedHosts": "*" +} \ No newline at end of file diff --git a/src/Web/config.nswag b/src/Web/config.nswag new file mode 100644 index 0000000..4af18f2 --- /dev/null +++ b/src/Web/config.nswag @@ -0,0 +1,63 @@ +{ + "runtime": "Net80", + "defaultVariables": null, + "documentGenerator": { + "aspNetCoreToOpenApi": { + "project": "Web.csproj", + "msBuildProjectExtensionsPath": null, + "configuration": null, + "runtime": null, + "targetFramework": null, + "noBuild": true, + "msBuildOutputPath": null, + "verbose": false, + "workingDirectory": null, + "requireParametersWithoutDefault": true, + "apiGroupNames": null, + "defaultPropertyNameHandling": "CamelCase", + "defaultReferenceTypeNullHandling": "Null", + "defaultDictionaryValueReferenceTypeNullHandling": "NotNull", + "defaultResponseReferenceTypeNullHandling": "NotNull", + "generateOriginalParameterNames": true, + "defaultEnumHandling": "Integer", + "flattenInheritanceHierarchy": false, + "generateKnownTypes": true, + "generateEnumMappingDescription": false, + "generateXmlObjects": false, + "generateAbstractProperties": false, + "generateAbstractSchemas": true, + "ignoreObsoleteProperties": false, + "allowReferencesWithProperties": false, + "useXmlDocumentation": true, + "resolveExternalXmlDocumentation": true, + "excludedTypeNames": [], + "serviceHost": null, + "serviceBasePath": null, + "serviceSchemes": [], + "infoTitle": "Hutopy API", + "infoDescription": null, + "infoVersion": "1.0.0", + "documentTemplate": null, + "documentProcessorTypes": [], + "operationProcessorTypes": [], + "typeNameGeneratorType": null, + "schemaNameGeneratorType": null, + "contractResolverType": null, + "serializerSettingsType": null, + "useDocumentProvider": true, + "documentName": "v1", + "aspNetCoreEnvironment": null, + "createWebHostBuilderMethod": null, + "startupType": null, + "allowNullableBodyParameters": true, + "useHttpAttributeNameAsOperationId": false, + "output": "wwwroot/api/specification.json", + "outputType": "OpenApi3", + "newLineBehavior": "Auto", + "assemblyPaths": [], + "assemblyConfig": null, + "referencePaths": [], + "useNuGetCache": false + } + } +} diff --git a/src/Web/wwwroot/api/specification.json b/src/Web/wwwroot/api/specification.json new file mode 100644 index 0000000..a8d81f4 --- /dev/null +++ b/src/Web/wwwroot/api/specification.json @@ -0,0 +1,1250 @@ +{ + "x-generator": "NSwag v14.0.3.0 (NJsonSchema v11.0.0.0 (Newtonsoft.Json v13.0.0.0))", + "openapi": "3.0.0", + "info": { + "title": "Hutopy API", + "version": "1.0.0" + }, + "paths": { + "/api/TodoItems": { + "get": { + "tags": [ + "TodoItems" + ], + "operationId": "GetTodoItemsWithPagination", + "parameters": [ + { + "name": "ListId", + "in": "query", + "required": true, + "schema": { + "type": "integer", + "format": "int32" + }, + "x-position": 1 + }, + { + "name": "PageNumber", + "in": "query", + "required": true, + "schema": { + "type": "integer", + "format": "int32" + }, + "x-position": 2 + }, + { + "name": "PageSize", + "in": "query", + "required": true, + "schema": { + "type": "integer", + "format": "int32" + }, + "x-position": 3 + } + ], + "responses": { + "200": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PaginatedListOfTodoItemBriefDto" + } + } + } + } + }, + "security": [ + { + "JWT": [] + } + ] + }, + "post": { + "tags": [ + "TodoItems" + ], + "operationId": "CreateTodoItem", + "requestBody": { + "x-name": "command", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CreateTodoItemCommand" + } + } + }, + "required": true, + "x-position": 1 + }, + "responses": { + "200": { + "description": "", + "content": { + "application/json": { + "schema": { + "type": "integer", + "format": "int32" + } + } + } + } + }, + "security": [ + { + "JWT": [] + } + ] + } + }, + "/api/TodoItems/{id}": { + "put": { + "tags": [ + "TodoItems" + ], + "operationId": "UpdateTodoItem", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "integer", + "format": "int32" + }, + "x-position": 1 + } + ], + "requestBody": { + "x-name": "command", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UpdateTodoItemCommand" + } + } + }, + "required": true, + "x-position": 2 + }, + "responses": { + "200": { + "description": "" + } + }, + "security": [ + { + "JWT": [] + } + ] + }, + "delete": { + "tags": [ + "TodoItems" + ], + "operationId": "DeleteTodoItem", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "integer", + "format": "int32" + }, + "x-position": 1 + } + ], + "responses": { + "200": { + "description": "" + } + }, + "security": [ + { + "JWT": [] + } + ] + } + }, + "/api/TodoItems/UpdateDetail/{id}": { + "put": { + "tags": [ + "TodoItems" + ], + "operationId": "UpdateTodoItemDetail", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "integer", + "format": "int32" + }, + "x-position": 1 + } + ], + "requestBody": { + "x-name": "command", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UpdateTodoItemDetailCommand" + } + } + }, + "required": true, + "x-position": 2 + }, + "responses": { + "200": { + "description": "" + } + }, + "security": [ + { + "JWT": [] + } + ] + } + }, + "/api/TodoLists": { + "get": { + "tags": [ + "TodoLists" + ], + "operationId": "GetTodoLists", + "responses": { + "200": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/TodosVm" + } + } + } + } + }, + "security": [ + { + "JWT": [] + } + ] + }, + "post": { + "tags": [ + "TodoLists" + ], + "operationId": "CreateTodoList", + "requestBody": { + "x-name": "command", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CreateTodoListCommand" + } + } + }, + "required": true, + "x-position": 1 + }, + "responses": { + "200": { + "description": "", + "content": { + "application/json": { + "schema": { + "type": "integer", + "format": "int32" + } + } + } + } + }, + "security": [ + { + "JWT": [] + } + ] + } + }, + "/api/TodoLists/{id}": { + "put": { + "tags": [ + "TodoLists" + ], + "operationId": "UpdateTodoList", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "integer", + "format": "int32" + }, + "x-position": 1 + } + ], + "requestBody": { + "x-name": "command", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UpdateTodoListCommand" + } + } + }, + "required": true, + "x-position": 2 + }, + "responses": { + "200": { + "description": "" + } + }, + "security": [ + { + "JWT": [] + } + ] + }, + "delete": { + "tags": [ + "TodoLists" + ], + "operationId": "DeleteTodoList", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "integer", + "format": "int32" + }, + "x-position": 1 + } + ], + "responses": { + "200": { + "description": "" + } + }, + "security": [ + { + "JWT": [] + } + ] + } + }, + "/api/Users/register": { + "post": { + "tags": [ + "Users" + ], + "operationId": "PostApiUsersRegister", + "requestBody": { + "x-name": "registration", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RegisterRequest" + } + } + }, + "x-position": 1 + }, + "responses": { + "200": { + "description": "" + }, + "400": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HttpValidationProblemDetails" + } + } + } + } + } + } + }, + "/api/Users/login": { + "post": { + "tags": [ + "Users" + ], + "operationId": "PostApiUsersLogin", + "parameters": [ + { + "name": "useCookies", + "in": "query", + "schema": { + "type": "boolean", + "nullable": true + }, + "x-position": 2 + }, + { + "name": "useSessionCookies", + "in": "query", + "schema": { + "type": "boolean", + "nullable": true + }, + "x-position": 3 + } + ], + "requestBody": { + "x-name": "login", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/LoginRequest" + } + } + }, + "x-position": 1 + }, + "responses": { + "200": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/AccessTokenResponse" + } + } + } + } + } + } + }, + "/api/Users/refresh": { + "post": { + "tags": [ + "Users" + ], + "operationId": "PostApiUsersRefresh", + "requestBody": { + "x-name": "refreshRequest", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RefreshRequest" + } + } + }, + "x-position": 1 + }, + "responses": { + "200": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/AccessTokenResponse" + } + } + } + } + } + } + }, + "/api/Users/confirmEmail": { + "get": { + "tags": [ + "Users" + ], + "operationId": "GetApiUsersConfirmEmail", + "parameters": [ + { + "name": "userId", + "in": "query", + "schema": { + "type": "string", + "nullable": true + }, + "x-position": 1 + }, + { + "name": "code", + "in": "query", + "schema": { + "type": "string", + "nullable": true + }, + "x-position": 2 + }, + { + "name": "changedEmail", + "in": "query", + "schema": { + "type": "string", + "nullable": true + }, + "x-position": 3 + } + ], + "responses": { + "200": { + "description": "" + } + } + } + }, + "/api/Users/resendConfirmationEmail": { + "post": { + "tags": [ + "Users" + ], + "operationId": "PostApiUsersResendConfirmationEmail", + "requestBody": { + "x-name": "resendRequest", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ResendConfirmationEmailRequest" + } + } + }, + "x-position": 1 + }, + "responses": { + "200": { + "description": "" + } + } + } + }, + "/api/Users/forgotPassword": { + "post": { + "tags": [ + "Users" + ], + "operationId": "PostApiUsersForgotPassword", + "requestBody": { + "x-name": "resetRequest", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ForgotPasswordRequest" + } + } + }, + "x-position": 1 + }, + "responses": { + "200": { + "description": "" + }, + "400": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HttpValidationProblemDetails" + } + } + } + } + } + } + }, + "/api/Users/resetPassword": { + "post": { + "tags": [ + "Users" + ], + "operationId": "PostApiUsersResetPassword", + "requestBody": { + "x-name": "resetRequest", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ResetPasswordRequest" + } + } + }, + "x-position": 1 + }, + "responses": { + "200": { + "description": "" + }, + "400": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HttpValidationProblemDetails" + } + } + } + } + } + } + }, + "/api/Users/manage/2fa": { + "post": { + "tags": [ + "Users" + ], + "operationId": "PostApiUsersManage2fa", + "requestBody": { + "x-name": "tfaRequest", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/TwoFactorRequest" + } + } + }, + "x-position": 1 + }, + "responses": { + "200": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/TwoFactorResponse" + } + } + } + }, + "400": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HttpValidationProblemDetails" + } + } + } + }, + "404": { + "description": "" + } + }, + "security": [ + { + "JWT": [] + } + ] + } + }, + "/api/Users/manage/info": { + "get": { + "tags": [ + "Users" + ], + "operationId": "GetApiUsersManageInfo", + "responses": { + "200": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/InfoResponse" + } + } + } + }, + "400": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HttpValidationProblemDetails" + } + } + } + }, + "404": { + "description": "" + } + }, + "security": [ + { + "JWT": [] + } + ] + }, + "post": { + "tags": [ + "Users" + ], + "operationId": "PostApiUsersManageInfo", + "requestBody": { + "x-name": "infoRequest", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/InfoRequest" + } + } + }, + "x-position": 1 + }, + "responses": { + "200": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/InfoResponse" + } + } + } + }, + "400": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HttpValidationProblemDetails" + } + } + } + }, + "404": { + "description": "" + } + }, + "security": [ + { + "JWT": [] + } + ] + } + }, + "/api/WeatherForecasts": { + "get": { + "tags": [ + "WeatherForecasts" + ], + "operationId": "GetWeatherForecasts", + "responses": { + "200": { + "description": "", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/WeatherForecast" + } + } + } + } + } + }, + "security": [ + { + "JWT": [] + } + ] + } + } + }, + "components": { + "schemas": { + "PaginatedListOfTodoItemBriefDto": { + "type": "object", + "additionalProperties": false, + "properties": { + "items": { + "type": "array", + "items": { + "$ref": "#/components/schemas/TodoItemBriefDto" + } + }, + "pageNumber": { + "type": "integer", + "format": "int32" + }, + "totalPages": { + "type": "integer", + "format": "int32" + }, + "totalCount": { + "type": "integer", + "format": "int32" + }, + "hasPreviousPage": { + "type": "boolean" + }, + "hasNextPage": { + "type": "boolean" + } + } + }, + "TodoItemBriefDto": { + "type": "object", + "additionalProperties": false, + "properties": { + "id": { + "type": "integer", + "format": "int32" + }, + "listId": { + "type": "integer", + "format": "int32" + }, + "title": { + "type": "string", + "nullable": true + }, + "done": { + "type": "boolean" + } + } + }, + "CreateTodoItemCommand": { + "type": "object", + "additionalProperties": false, + "properties": { + "listId": { + "type": "integer", + "format": "int32" + }, + "title": { + "type": "string", + "nullable": true + } + } + }, + "UpdateTodoItemCommand": { + "type": "object", + "additionalProperties": false, + "properties": { + "id": { + "type": "integer", + "format": "int32" + }, + "title": { + "type": "string", + "nullable": true + }, + "done": { + "type": "boolean" + } + } + }, + "UpdateTodoItemDetailCommand": { + "type": "object", + "additionalProperties": false, + "properties": { + "id": { + "type": "integer", + "format": "int32" + }, + "listId": { + "type": "integer", + "format": "int32" + }, + "priority": { + "$ref": "#/components/schemas/PriorityLevel" + }, + "note": { + "type": "string", + "nullable": true + } + } + }, + "PriorityLevel": { + "type": "integer", + "description": "", + "x-enumNames": [ + "None", + "Low", + "Medium", + "High" + ], + "enum": [ + 0, + 1, + 2, + 3 + ] + }, + "TodosVm": { + "type": "object", + "additionalProperties": false, + "properties": { + "priorityLevels": { + "type": "array", + "items": { + "$ref": "#/components/schemas/LookupDto" + } + }, + "lists": { + "type": "array", + "items": { + "$ref": "#/components/schemas/TodoListDto" + } + } + } + }, + "LookupDto": { + "type": "object", + "additionalProperties": false, + "properties": { + "id": { + "type": "integer", + "format": "int32" + }, + "title": { + "type": "string", + "nullable": true + } + } + }, + "TodoListDto": { + "type": "object", + "additionalProperties": false, + "properties": { + "id": { + "type": "integer", + "format": "int32" + }, + "title": { + "type": "string", + "nullable": true + }, + "colour": { + "type": "string", + "nullable": true + }, + "items": { + "type": "array", + "items": { + "$ref": "#/components/schemas/TodoItemDto" + } + } + } + }, + "TodoItemDto": { + "type": "object", + "additionalProperties": false, + "properties": { + "id": { + "type": "integer", + "format": "int32" + }, + "listId": { + "type": "integer", + "format": "int32" + }, + "title": { + "type": "string", + "nullable": true + }, + "done": { + "type": "boolean" + }, + "priority": { + "type": "integer", + "format": "int32" + }, + "note": { + "type": "string", + "nullable": true + } + } + }, + "CreateTodoListCommand": { + "type": "object", + "additionalProperties": false, + "properties": { + "title": { + "type": "string", + "nullable": true + } + } + }, + "UpdateTodoListCommand": { + "type": "object", + "additionalProperties": false, + "properties": { + "id": { + "type": "integer", + "format": "int32" + }, + "title": { + "type": "string", + "nullable": true + } + } + }, + "HttpValidationProblemDetails": { + "allOf": [ + { + "$ref": "#/components/schemas/ProblemDetails" + }, + { + "type": "object", + "additionalProperties": { + "nullable": true + }, + "properties": { + "errors": { + "type": "object", + "additionalProperties": { + "type": "array", + "items": { + "type": "string" + } + } + } + } + } + ] + }, + "ProblemDetails": { + "type": "object", + "additionalProperties": { + "nullable": true + }, + "properties": { + "type": { + "type": "string", + "nullable": true + }, + "title": { + "type": "string", + "nullable": true + }, + "status": { + "type": "integer", + "format": "int32", + "nullable": true + }, + "detail": { + "type": "string", + "nullable": true + }, + "instance": { + "type": "string", + "nullable": true + } + } + }, + "RegisterRequest": { + "type": "object", + "additionalProperties": false, + "properties": { + "email": { + "type": "string" + }, + "password": { + "type": "string" + } + } + }, + "AccessTokenResponse": { + "type": "object", + "additionalProperties": false, + "properties": { + "tokenType": { + "type": "string" + }, + "accessToken": { + "type": "string" + }, + "expiresIn": { + "type": "integer", + "format": "int64" + }, + "refreshToken": { + "type": "string" + } + } + }, + "LoginRequest": { + "type": "object", + "additionalProperties": false, + "properties": { + "email": { + "type": "string" + }, + "password": { + "type": "string" + }, + "twoFactorCode": { + "type": "string", + "nullable": true + }, + "twoFactorRecoveryCode": { + "type": "string", + "nullable": true + } + } + }, + "RefreshRequest": { + "type": "object", + "additionalProperties": false, + "properties": { + "refreshToken": { + "type": "string" + } + } + }, + "ResendConfirmationEmailRequest": { + "type": "object", + "additionalProperties": false, + "properties": { + "email": { + "type": "string" + } + } + }, + "ForgotPasswordRequest": { + "type": "object", + "additionalProperties": false, + "properties": { + "email": { + "type": "string" + } + } + }, + "ResetPasswordRequest": { + "type": "object", + "additionalProperties": false, + "properties": { + "email": { + "type": "string" + }, + "resetCode": { + "type": "string" + }, + "newPassword": { + "type": "string" + } + } + }, + "TwoFactorResponse": { + "type": "object", + "additionalProperties": false, + "properties": { + "sharedKey": { + "type": "string" + }, + "recoveryCodesLeft": { + "type": "integer", + "format": "int32" + }, + "recoveryCodes": { + "type": "array", + "nullable": true, + "items": { + "type": "string" + } + }, + "isTwoFactorEnabled": { + "type": "boolean" + }, + "isMachineRemembered": { + "type": "boolean" + } + } + }, + "TwoFactorRequest": { + "type": "object", + "additionalProperties": false, + "properties": { + "enable": { + "type": "boolean", + "nullable": true + }, + "twoFactorCode": { + "type": "string", + "nullable": true + }, + "resetSharedKey": { + "type": "boolean" + }, + "resetRecoveryCodes": { + "type": "boolean" + }, + "forgetMachine": { + "type": "boolean" + } + } + }, + "InfoResponse": { + "type": "object", + "additionalProperties": false, + "properties": { + "email": { + "type": "string" + }, + "isEmailConfirmed": { + "type": "boolean" + } + } + }, + "InfoRequest": { + "type": "object", + "additionalProperties": false, + "properties": { + "newEmail": { + "type": "string", + "nullable": true + }, + "newPassword": { + "type": "string", + "nullable": true + }, + "oldPassword": { + "type": "string", + "nullable": true + } + } + }, + "WeatherForecast": { + "type": "object", + "additionalProperties": false, + "properties": { + "date": { + "type": "string", + "format": "date-time" + }, + "temperatureC": { + "type": "integer", + "format": "int32" + }, + "temperatureF": { + "type": "integer", + "format": "int32" + }, + "summary": { + "type": "string", + "nullable": true + } + } + } + }, + "securitySchemes": { + "JWT": { + "type": "apiKey", + "description": "Type into the textbox: Bearer {your JWT token}.", + "name": "Authorization", + "in": "header" + } + } + }, + "security": [ + { + "JWT": [] + } + ] +} \ No newline at end of file diff --git a/src/Web/wwwroot/favicon.ico b/src/Web/wwwroot/favicon.ico new file mode 100644 index 0000000..63e859b Binary files /dev/null and b/src/Web/wwwroot/favicon.ico differ diff --git a/tests/Application.FunctionalTests/Application.FunctionalTests.csproj b/tests/Application.FunctionalTests/Application.FunctionalTests.csproj new file mode 100644 index 0000000..c3a8232 --- /dev/null +++ b/tests/Application.FunctionalTests/Application.FunctionalTests.csproj @@ -0,0 +1,40 @@ + + + + Hutopy.Application.FunctionalTests + Hutopy.Application.FunctionalTests + + + + + + + + + PreserveNewest + true + PreserveNewest + + + + + + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + + + + + + + + + + + + diff --git a/tests/Application.FunctionalTests/BaseTestFixture.cs b/tests/Application.FunctionalTests/BaseTestFixture.cs new file mode 100644 index 0000000..27606ee --- /dev/null +++ b/tests/Application.FunctionalTests/BaseTestFixture.cs @@ -0,0 +1,13 @@ +namespace Hutopy.Application.FunctionalTests; + +using static Testing; + +[TestFixture] +public abstract class BaseTestFixture +{ + [SetUp] + public async Task TestSetUp() + { + await ResetState(); + } +} diff --git a/tests/Application.FunctionalTests/CustomWebApplicationFactory.cs b/tests/Application.FunctionalTests/CustomWebApplicationFactory.cs new file mode 100644 index 0000000..d99727f --- /dev/null +++ b/tests/Application.FunctionalTests/CustomWebApplicationFactory.cs @@ -0,0 +1,42 @@ +using System.Data.Common; +using Hutopy.Application.Common.Interfaces; +using Hutopy.Infrastructure.Data; +using Microsoft.AspNetCore.Hosting; +using Microsoft.AspNetCore.Mvc.Testing; +using Microsoft.AspNetCore.TestHost; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Diagnostics; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.DependencyInjection.Extensions; + +namespace Hutopy.Application.FunctionalTests; + +using static Testing; + +public class CustomWebApplicationFactory : WebApplicationFactory +{ + private readonly DbConnection _connection; + + public CustomWebApplicationFactory(DbConnection connection) + { + _connection = connection; + } + + protected override void ConfigureWebHost(IWebHostBuilder builder) + { + builder.ConfigureTestServices(services => + { + services + .RemoveAll() + .AddTransient(provider => Mock.Of(s => s.Id == GetUserId())); + + services + .RemoveAll>() + .AddDbContext((sp, options) => + { + options.AddInterceptors(sp.GetServices()); + options.UseSqlServer(_connection); + }); + }); + } +} diff --git a/tests/Application.FunctionalTests/GlobalUsings.cs b/tests/Application.FunctionalTests/GlobalUsings.cs new file mode 100644 index 0000000..dd2bce7 --- /dev/null +++ b/tests/Application.FunctionalTests/GlobalUsings.cs @@ -0,0 +1,4 @@ +global using Ardalis.GuardClauses; +global using FluentAssertions; +global using Moq; +global using NUnit.Framework; \ No newline at end of file diff --git a/tests/Application.FunctionalTests/ITestDatabase.cs b/tests/Application.FunctionalTests/ITestDatabase.cs new file mode 100644 index 0000000..b3ffc55 --- /dev/null +++ b/tests/Application.FunctionalTests/ITestDatabase.cs @@ -0,0 +1,14 @@ +using System.Data.Common; + +namespace Hutopy.Application.FunctionalTests; + +public interface ITestDatabase +{ + Task InitialiseAsync(); + + DbConnection GetConnection(); + + Task ResetAsync(); + + Task DisposeAsync(); +} diff --git a/tests/Application.FunctionalTests/SqlServerTestDatabase.cs b/tests/Application.FunctionalTests/SqlServerTestDatabase.cs new file mode 100644 index 0000000..57e7acc --- /dev/null +++ b/tests/Application.FunctionalTests/SqlServerTestDatabase.cs @@ -0,0 +1,62 @@ +using System.Data.Common; +using Hutopy.Infrastructure.Data; +using Microsoft.Data.SqlClient; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Configuration; +using Respawn; + +namespace Hutopy.Application.FunctionalTests; + +public class SqlServerTestDatabase : ITestDatabase +{ + private readonly string _connectionString = null!; + private SqlConnection _connection = null!; + private Respawner _respawner = null!; + + public SqlServerTestDatabase() + { + var configuration = new ConfigurationBuilder() + .AddJsonFile("appsettings.json") + .AddEnvironmentVariables() + .Build(); + + var connectionString = configuration.GetConnectionString("DefaultConnection"); + + Guard.Against.Null(connectionString); + + _connectionString = connectionString; + } + + public async Task InitialiseAsync() + { + _connection = new SqlConnection(_connectionString); + + var options = new DbContextOptionsBuilder() + .UseSqlServer(_connectionString) + .Options; + + var context = new ApplicationDbContext(options); + + context.Database.Migrate(); + + _respawner = await Respawner.CreateAsync(_connectionString, new RespawnerOptions + { + TablesToIgnore = new Respawn.Graph.Table[] { "__EFMigrationsHistory" } + }); + } + + public DbConnection GetConnection() + { + return _connection; + } + + public async Task ResetAsync() + { + await _respawner.ResetAsync(_connectionString); + } + + public async Task DisposeAsync() + { + await _connection.DisposeAsync(); + } +} diff --git a/tests/Application.FunctionalTests/TestDatabaseFactory.cs b/tests/Application.FunctionalTests/TestDatabaseFactory.cs new file mode 100644 index 0000000..0164872 --- /dev/null +++ b/tests/Application.FunctionalTests/TestDatabaseFactory.cs @@ -0,0 +1,13 @@ +namespace Hutopy.Application.FunctionalTests; + +public static class TestDatabaseFactory +{ + public static async Task CreateAsync() + { + var database = new TestcontainersTestDatabase(); + + await database.InitialiseAsync(); + + return database; + } +} diff --git a/tests/Application.FunctionalTests/TestcontainersTestDatabase.cs b/tests/Application.FunctionalTests/TestcontainersTestDatabase.cs new file mode 100644 index 0000000..eaf57f8 --- /dev/null +++ b/tests/Application.FunctionalTests/TestcontainersTestDatabase.cs @@ -0,0 +1,61 @@ +using System.Data.Common; +using Hutopy.Infrastructure.Data; +using Microsoft.Data.SqlClient; +using Microsoft.EntityFrameworkCore; +using Respawn; +using Testcontainers.MsSql; + +namespace Hutopy.Application.FunctionalTests; + +public class TestcontainersTestDatabase : ITestDatabase +{ + private readonly MsSqlContainer _container; + private DbConnection _connection = null!; + private string _connectionString = null!; + private Respawner _respawner = null!; + + public TestcontainersTestDatabase() + { + _container = new MsSqlBuilder() + .WithAutoRemove(true) + .Build(); + } + + public async Task InitialiseAsync() + { + await _container.StartAsync(); + + _connectionString = _container.GetConnectionString(); + + _connection = new SqlConnection(_connectionString); + + var options = new DbContextOptionsBuilder() + .UseSqlServer(_connectionString) + .Options; + + var context = new ApplicationDbContext(options); + + context.Database.Migrate(); + + _respawner = await Respawner.CreateAsync(_connectionString, new RespawnerOptions + { + TablesToIgnore = new Respawn.Graph.Table[] { "__EFMigrationsHistory" } + }); + } + + public DbConnection GetConnection() + { + return _connection; + } + + public async Task ResetAsync() + { + await _respawner.ResetAsync(_connectionString); + } + + public async Task DisposeAsync() + { + await _connection.DisposeAsync(); + await _container.DisposeAsync(); + } +} diff --git a/tests/Application.FunctionalTests/Testing.cs b/tests/Application.FunctionalTests/Testing.cs new file mode 100644 index 0000000..49d8384 --- /dev/null +++ b/tests/Application.FunctionalTests/Testing.cs @@ -0,0 +1,146 @@ +using Hutopy.Domain.Constants; +using Hutopy.Infrastructure.Data; +using Hutopy.Infrastructure.Identity; +using MediatR; +using Microsoft.AspNetCore.Identity; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.DependencyInjection; + +namespace Hutopy.Application.FunctionalTests; + +[SetUpFixture] +public partial class Testing +{ + private static ITestDatabase _database; + private static CustomWebApplicationFactory _factory = null!; + private static IServiceScopeFactory _scopeFactory = null!; + private static string? _userId; + + [OneTimeSetUp] + public async Task RunBeforeAnyTests() + { + _database = await TestDatabaseFactory.CreateAsync(); + + _factory = new CustomWebApplicationFactory(_database.GetConnection()); + + _scopeFactory = _factory.Services.GetRequiredService(); + } + + public static async Task SendAsync(IRequest request) + { + using var scope = _scopeFactory.CreateScope(); + + var mediator = scope.ServiceProvider.GetRequiredService(); + + return await mediator.Send(request); + } + + public static async Task SendAsync(IBaseRequest request) + { + using var scope = _scopeFactory.CreateScope(); + + var mediator = scope.ServiceProvider.GetRequiredService(); + + await mediator.Send(request); + } + + public static string? GetUserId() + { + return _userId; + } + + public static async Task RunAsDefaultUserAsync() + { + return await RunAsUserAsync("test@local", "Testing1234!", Array.Empty()); + } + + public static async Task RunAsAdministratorAsync() + { + return await RunAsUserAsync("administrator@local", "Administrator1234!", new[] { Roles.Administrator }); + } + + public static async Task RunAsUserAsync(string userName, string password, string[] roles) + { + using var scope = _scopeFactory.CreateScope(); + + var userManager = scope.ServiceProvider.GetRequiredService>(); + + var user = new ApplicationUser { UserName = userName, Email = userName }; + + var result = await userManager.CreateAsync(user, password); + + if (roles.Any()) + { + var roleManager = scope.ServiceProvider.GetRequiredService>(); + + foreach (var role in roles) + { + await roleManager.CreateAsync(new IdentityRole(role)); + } + + await userManager.AddToRolesAsync(user, roles); + } + + if (result.Succeeded) + { + _userId = user.Id; + + return _userId; + } + + var errors = string.Join(Environment.NewLine, result.ToApplicationResult().Errors); + + throw new Exception($"Unable to create {userName}.{Environment.NewLine}{errors}"); + } + + public static async Task ResetState() + { + try + { + await _database.ResetAsync(); + } + catch (Exception) + { + } + + _userId = null; + } + + public static async Task FindAsync(params object[] keyValues) + where TEntity : class + { + using var scope = _scopeFactory.CreateScope(); + + var context = scope.ServiceProvider.GetRequiredService(); + + return await context.FindAsync(keyValues); + } + + public static async Task AddAsync(TEntity entity) + where TEntity : class + { + using var scope = _scopeFactory.CreateScope(); + + var context = scope.ServiceProvider.GetRequiredService(); + + context.Add(entity); + + await context.SaveChangesAsync(); + } + + public static async Task CountAsync() where TEntity : class + { + using var scope = _scopeFactory.CreateScope(); + + var context = scope.ServiceProvider.GetRequiredService(); + + return await context.Set().CountAsync(); + } + + [OneTimeTearDown] + public async Task RunAfterAnyTests() + { + await _database.DisposeAsync(); + await _factory.DisposeAsync(); + } +} diff --git a/tests/Application.FunctionalTests/TodoItems/Commands/CreateTodoItemTests.cs b/tests/Application.FunctionalTests/TodoItems/Commands/CreateTodoItemTests.cs new file mode 100644 index 0000000..b228ab0 --- /dev/null +++ b/tests/Application.FunctionalTests/TodoItems/Commands/CreateTodoItemTests.cs @@ -0,0 +1,49 @@ +using Hutopy.Application.Common.Exceptions; +using Hutopy.Application.TodoItems.Commands.CreateTodoItem; +using Hutopy.Application.TodoLists.Commands.CreateTodoList; +using Hutopy.Domain.Entities; + +namespace Hutopy.Application.FunctionalTests.TodoItems.Commands; + +using static Testing; + +public class CreateTodoItemTests : BaseTestFixture +{ + [Test] + public async Task ShouldRequireMinimumFields() + { + var command = new CreateTodoItemCommand(); + + await FluentActions.Invoking(() => + SendAsync(command)).Should().ThrowAsync(); + } + + [Test] + public async Task ShouldCreateTodoItem() + { + var userId = await RunAsDefaultUserAsync(); + + var listId = await SendAsync(new CreateTodoListCommand + { + Title = "New List" + }); + + var command = new CreateTodoItemCommand + { + ListId = listId, + Title = "Tasks" + }; + + var itemId = await SendAsync(command); + + var item = await FindAsync(itemId); + + item.Should().NotBeNull(); + item!.ListId.Should().Be(command.ListId); + item.Title.Should().Be(command.Title); + item.CreatedBy.Should().Be(userId); + item.Created.Should().BeCloseTo(DateTime.Now, TimeSpan.FromMilliseconds(10000)); + item.LastModifiedBy.Should().Be(userId); + item.LastModified.Should().BeCloseTo(DateTime.Now, TimeSpan.FromMilliseconds(10000)); + } +} diff --git a/tests/Application.FunctionalTests/TodoItems/Commands/DeleteTodoItemTests.cs b/tests/Application.FunctionalTests/TodoItems/Commands/DeleteTodoItemTests.cs new file mode 100644 index 0000000..bb0e1f3 --- /dev/null +++ b/tests/Application.FunctionalTests/TodoItems/Commands/DeleteTodoItemTests.cs @@ -0,0 +1,41 @@ +using Hutopy.Application.TodoItems.Commands.CreateTodoItem; +using Hutopy.Application.TodoItems.Commands.DeleteTodoItem; +using Hutopy.Application.TodoLists.Commands.CreateTodoList; +using Hutopy.Domain.Entities; + +namespace Hutopy.Application.FunctionalTests.TodoItems.Commands; + +using static Testing; + +public class DeleteTodoItemTests : BaseTestFixture +{ + [Test] + public async Task ShouldRequireValidTodoItemId() + { + var command = new DeleteTodoItemCommand(99); + + await FluentActions.Invoking(() => + SendAsync(command)).Should().ThrowAsync(); + } + + [Test] + public async Task ShouldDeleteTodoItem() + { + var listId = await SendAsync(new CreateTodoListCommand + { + Title = "New List" + }); + + var itemId = await SendAsync(new CreateTodoItemCommand + { + ListId = listId, + Title = "New Item" + }); + + await SendAsync(new DeleteTodoItemCommand(itemId)); + + var item = await FindAsync(itemId); + + item.Should().BeNull(); + } +} diff --git a/tests/Application.FunctionalTests/TodoItems/Commands/UpdateTodoItemDetailTests.cs b/tests/Application.FunctionalTests/TodoItems/Commands/UpdateTodoItemDetailTests.cs new file mode 100644 index 0000000..63486bb --- /dev/null +++ b/tests/Application.FunctionalTests/TodoItems/Commands/UpdateTodoItemDetailTests.cs @@ -0,0 +1,57 @@ +using Hutopy.Application.TodoItems.Commands.CreateTodoItem; +using Hutopy.Application.TodoItems.Commands.UpdateTodoItem; +using Hutopy.Application.TodoItems.Commands.UpdateTodoItemDetail; +using Hutopy.Application.TodoLists.Commands.CreateTodoList; +using Hutopy.Domain.Entities; +using Hutopy.Domain.Enums; + +namespace Hutopy.Application.FunctionalTests.TodoItems.Commands; + +using static Testing; + +public class UpdateTodoItemDetailTests : BaseTestFixture +{ + [Test] + public async Task ShouldRequireValidTodoItemId() + { + var command = new UpdateTodoItemCommand { Id = 99, Title = "New Title" }; + await FluentActions.Invoking(() => SendAsync(command)).Should().ThrowAsync(); + } + + [Test] + public async Task ShouldUpdateTodoItem() + { + var userId = await RunAsDefaultUserAsync(); + + var listId = await SendAsync(new CreateTodoListCommand + { + Title = "New List" + }); + + var itemId = await SendAsync(new CreateTodoItemCommand + { + ListId = listId, + Title = "New Item" + }); + + var command = new UpdateTodoItemDetailCommand + { + Id = itemId, + ListId = listId, + Note = "This is the note.", + Priority = PriorityLevel.High + }; + + await SendAsync(command); + + var item = await FindAsync(itemId); + + item.Should().NotBeNull(); + item!.ListId.Should().Be(command.ListId); + item.Note.Should().Be(command.Note); + item.Priority.Should().Be(command.Priority); + item.LastModifiedBy.Should().NotBeNull(); + item.LastModifiedBy.Should().Be(userId); + item.LastModified.Should().BeCloseTo(DateTime.Now, TimeSpan.FromMilliseconds(10000)); + } +} diff --git a/tests/Application.FunctionalTests/TodoItems/Commands/UpdateTodoItemTests.cs b/tests/Application.FunctionalTests/TodoItems/Commands/UpdateTodoItemTests.cs new file mode 100644 index 0000000..b3e3ebf --- /dev/null +++ b/tests/Application.FunctionalTests/TodoItems/Commands/UpdateTodoItemTests.cs @@ -0,0 +1,51 @@ +using Hutopy.Application.TodoItems.Commands.CreateTodoItem; +using Hutopy.Application.TodoItems.Commands.UpdateTodoItem; +using Hutopy.Application.TodoLists.Commands.CreateTodoList; +using Hutopy.Domain.Entities; + +namespace Hutopy.Application.FunctionalTests.TodoItems.Commands; + +using static Testing; + +public class UpdateTodoItemTests : BaseTestFixture +{ + [Test] + public async Task ShouldRequireValidTodoItemId() + { + var command = new UpdateTodoItemCommand { Id = 99, Title = "New Title" }; + await FluentActions.Invoking(() => SendAsync(command)).Should().ThrowAsync(); + } + + [Test] + public async Task ShouldUpdateTodoItem() + { + var userId = await RunAsDefaultUserAsync(); + + var listId = await SendAsync(new CreateTodoListCommand + { + Title = "New List" + }); + + var itemId = await SendAsync(new CreateTodoItemCommand + { + ListId = listId, + Title = "New Item" + }); + + var command = new UpdateTodoItemCommand + { + Id = itemId, + Title = "Updated Item Title" + }; + + await SendAsync(command); + + var item = await FindAsync(itemId); + + item.Should().NotBeNull(); + item!.Title.Should().Be(command.Title); + item.LastModifiedBy.Should().NotBeNull(); + item.LastModifiedBy.Should().Be(userId); + item.LastModified.Should().BeCloseTo(DateTime.Now, TimeSpan.FromMilliseconds(10000)); + } +} diff --git a/tests/Application.FunctionalTests/TodoLists/Commands/CreateTodoListTests.cs b/tests/Application.FunctionalTests/TodoLists/Commands/CreateTodoListTests.cs new file mode 100644 index 0000000..2b6edab --- /dev/null +++ b/tests/Application.FunctionalTests/TodoLists/Commands/CreateTodoListTests.cs @@ -0,0 +1,54 @@ +using Hutopy.Application.Common.Exceptions; +using Hutopy.Application.TodoLists.Commands.CreateTodoList; +using Hutopy.Domain.Entities; + +namespace Hutopy.Application.FunctionalTests.TodoLists.Commands; + +using static Testing; + +public class CreateTodoListTests : BaseTestFixture +{ + [Test] + public async Task ShouldRequireMinimumFields() + { + var command = new CreateTodoListCommand(); + await FluentActions.Invoking(() => SendAsync(command)).Should().ThrowAsync(); + } + + [Test] + public async Task ShouldRequireUniqueTitle() + { + await SendAsync(new CreateTodoListCommand + { + Title = "Shopping" + }); + + var command = new CreateTodoListCommand + { + Title = "Shopping" + }; + + await FluentActions.Invoking(() => + SendAsync(command)).Should().ThrowAsync(); + } + + [Test] + public async Task ShouldCreateTodoList() + { + var userId = await RunAsDefaultUserAsync(); + + var command = new CreateTodoListCommand + { + Title = "Tasks" + }; + + var id = await SendAsync(command); + + var list = await FindAsync(id); + + list.Should().NotBeNull(); + list!.Title.Should().Be(command.Title); + list.CreatedBy.Should().Be(userId); + list.Created.Should().BeCloseTo(DateTime.Now, TimeSpan.FromMilliseconds(10000)); + } +} diff --git a/tests/Application.FunctionalTests/TodoLists/Commands/DeleteTodoListTests.cs b/tests/Application.FunctionalTests/TodoLists/Commands/DeleteTodoListTests.cs new file mode 100644 index 0000000..446992c --- /dev/null +++ b/tests/Application.FunctionalTests/TodoLists/Commands/DeleteTodoListTests.cs @@ -0,0 +1,32 @@ +using Hutopy.Application.TodoLists.Commands.CreateTodoList; +using Hutopy.Application.TodoLists.Commands.DeleteTodoList; +using Hutopy.Domain.Entities; + +namespace Hutopy.Application.FunctionalTests.TodoLists.Commands; + +using static Testing; + +public class DeleteTodoListTests : BaseTestFixture +{ + [Test] + public async Task ShouldRequireValidTodoListId() + { + var command = new DeleteTodoListCommand(99); + await FluentActions.Invoking(() => SendAsync(command)).Should().ThrowAsync(); + } + + [Test] + public async Task ShouldDeleteTodoList() + { + var listId = await SendAsync(new CreateTodoListCommand + { + Title = "New List" + }); + + await SendAsync(new DeleteTodoListCommand(listId)); + + var list = await FindAsync(listId); + + list.Should().BeNull(); + } +} diff --git a/tests/Application.FunctionalTests/TodoLists/Commands/PurgeTodoListsTests.cs b/tests/Application.FunctionalTests/TodoLists/Commands/PurgeTodoListsTests.cs new file mode 100644 index 0000000..7b92631 --- /dev/null +++ b/tests/Application.FunctionalTests/TodoLists/Commands/PurgeTodoListsTests.cs @@ -0,0 +1,75 @@ +using Hutopy.Application.Common.Exceptions; +using Hutopy.Application.Common.Security; +using Hutopy.Application.TodoLists.Commands.CreateTodoList; +using Hutopy.Application.TodoLists.Commands.PurgeTodoLists; +using Hutopy.Domain.Entities; + +namespace Hutopy.Application.FunctionalTests.TodoLists.Commands; + +using static Testing; + +public class PurgeTodoListsTests : BaseTestFixture +{ + [Test] + public async Task ShouldDenyAnonymousUser() + { + var command = new PurgeTodoListsCommand(); + + command.GetType().Should().BeDecoratedWith(); + + var action = () => SendAsync(command); + + await action.Should().ThrowAsync(); + } + + [Test] + public async Task ShouldDenyNonAdministrator() + { + await RunAsDefaultUserAsync(); + + var command = new PurgeTodoListsCommand(); + + var action = () => SendAsync(command); + + await action.Should().ThrowAsync(); + } + + [Test] + public async Task ShouldAllowAdministrator() + { + await RunAsAdministratorAsync(); + + var command = new PurgeTodoListsCommand(); + + var action = () => SendAsync(command); + + await action.Should().NotThrowAsync(); + } + + [Test] + public async Task ShouldDeleteAllLists() + { + await RunAsAdministratorAsync(); + + await SendAsync(new CreateTodoListCommand + { + Title = "New List #1" + }); + + await SendAsync(new CreateTodoListCommand + { + Title = "New List #2" + }); + + await SendAsync(new CreateTodoListCommand + { + Title = "New List #3" + }); + + await SendAsync(new PurgeTodoListsCommand()); + + var count = await CountAsync(); + + count.Should().Be(0); + } +} diff --git a/tests/Application.FunctionalTests/TodoLists/Commands/UpdateTodoListTests.cs b/tests/Application.FunctionalTests/TodoLists/Commands/UpdateTodoListTests.cs new file mode 100644 index 0000000..37d46d2 --- /dev/null +++ b/tests/Application.FunctionalTests/TodoLists/Commands/UpdateTodoListTests.cs @@ -0,0 +1,70 @@ +using Hutopy.Application.Common.Exceptions; +using Hutopy.Application.TodoLists.Commands.CreateTodoList; +using Hutopy.Application.TodoLists.Commands.UpdateTodoList; +using Hutopy.Domain.Entities; + +namespace Hutopy.Application.FunctionalTests.TodoLists.Commands; + +using static Testing; + +public class UpdateTodoListTests : BaseTestFixture +{ + [Test] + public async Task ShouldRequireValidTodoListId() + { + var command = new UpdateTodoListCommand { Id = 99, Title = "New Title" }; + await FluentActions.Invoking(() => SendAsync(command)).Should().ThrowAsync(); + } + + [Test] + public async Task ShouldRequireUniqueTitle() + { + var listId = await SendAsync(new CreateTodoListCommand + { + Title = "New List" + }); + + await SendAsync(new CreateTodoListCommand + { + Title = "Other List" + }); + + var command = new UpdateTodoListCommand + { + Id = listId, + Title = "Other List" + }; + + (await FluentActions.Invoking(() => + SendAsync(command)) + .Should().ThrowAsync().Where(ex => ex.Errors.ContainsKey("Title"))) + .And.Errors["Title"].Should().Contain("'Title' must be unique."); + } + + [Test] + public async Task ShouldUpdateTodoList() + { + var userId = await RunAsDefaultUserAsync(); + + var listId = await SendAsync(new CreateTodoListCommand + { + Title = "New List" + }); + + var command = new UpdateTodoListCommand + { + Id = listId, + Title = "Updated List Title" + }; + + await SendAsync(command); + + var list = await FindAsync(listId); + + list.Should().NotBeNull(); + list!.Title.Should().Be(command.Title); + list.LastModifiedBy.Should().NotBeNull(); + list.LastModifiedBy.Should().Be(userId); + list.LastModified.Should().BeCloseTo(DateTime.Now, TimeSpan.FromMilliseconds(10000)); + } +} diff --git a/tests/Application.FunctionalTests/TodoLists/Queries/GetTodosTests.cs b/tests/Application.FunctionalTests/TodoLists/Queries/GetTodosTests.cs new file mode 100644 index 0000000..2de1eda --- /dev/null +++ b/tests/Application.FunctionalTests/TodoLists/Queries/GetTodosTests.cs @@ -0,0 +1,61 @@ +using Hutopy.Application.TodoLists.Queries.GetTodos; +using Hutopy.Domain.Entities; +using Hutopy.Domain.ValueObjects; + +namespace Hutopy.Application.FunctionalTests.TodoLists.Queries; + +using static Testing; + +public class GetTodosTests : BaseTestFixture +{ + [Test] + public async Task ShouldReturnPriorityLevels() + { + await RunAsDefaultUserAsync(); + + var query = new GetTodosQuery(); + + var result = await SendAsync(query); + + result.PriorityLevels.Should().NotBeEmpty(); + } + + [Test] + public async Task ShouldReturnAllListsAndItems() + { + await RunAsDefaultUserAsync(); + + await AddAsync(new TodoList + { + Title = "Shopping", + Colour = Colour.Blue, + Items = + { + new TodoItem { Title = "Apples", Done = true }, + new TodoItem { Title = "Milk", Done = true }, + new TodoItem { Title = "Bread", Done = true }, + new TodoItem { Title = "Toilet paper" }, + new TodoItem { Title = "Pasta" }, + new TodoItem { Title = "Tissues" }, + new TodoItem { Title = "Tuna" } + } + }); + + var query = new GetTodosQuery(); + + var result = await SendAsync(query); + + result.Lists.Should().HaveCount(1); + result.Lists.First().Items.Should().HaveCount(7); + } + + [Test] + public async Task ShouldDenyAnonymousUser() + { + var query = new GetTodosQuery(); + + var action = () => SendAsync(query); + + await action.Should().ThrowAsync(); + } +} diff --git a/tests/Application.FunctionalTests/appsettings.json b/tests/Application.FunctionalTests/appsettings.json new file mode 100644 index 0000000..ea0855a --- /dev/null +++ b/tests/Application.FunctionalTests/appsettings.json @@ -0,0 +1,5 @@ +{ + "ConnectionStrings": { + "DefaultConnection": "Server=(localdb)\\mssqllocaldb;Database=HutopyTestDb;Trusted_Connection=True;MultipleActiveResultSets=true" + } +} diff --git a/tests/Application.UnitTests/Application.UnitTests.csproj b/tests/Application.UnitTests/Application.UnitTests.csproj new file mode 100644 index 0000000..805128b --- /dev/null +++ b/tests/Application.UnitTests/Application.UnitTests.csproj @@ -0,0 +1,26 @@ + + + + Hutopy.Application.UnitTests + Hutopy.Application.UnitTests + + + + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + + + + + + + + + + + diff --git a/tests/Application.UnitTests/Common/Behaviours/RequestLoggerTests.cs b/tests/Application.UnitTests/Common/Behaviours/RequestLoggerTests.cs new file mode 100644 index 0000000..31ee2a5 --- /dev/null +++ b/tests/Application.UnitTests/Common/Behaviours/RequestLoggerTests.cs @@ -0,0 +1,45 @@ +using Hutopy.Application.Common.Behaviours; +using Hutopy.Application.Common.Interfaces; +using Hutopy.Application.TodoItems.Commands.CreateTodoItem; +using Microsoft.Extensions.Logging; +using Moq; +using NUnit.Framework; + +namespace Hutopy.Application.UnitTests.Common.Behaviours; + +public class RequestLoggerTests +{ + private Mock> _logger = null!; + private Mock _user = null!; + private Mock _identityService = null!; + + [SetUp] + public void Setup() + { + _logger = new Mock>(); + _user = new Mock(); + _identityService = new Mock(); + } + + [Test] + public async Task ShouldCallGetUserNameAsyncOnceIfAuthenticated() + { + _user.Setup(x => x.Id).Returns(Guid.NewGuid().ToString()); + + var requestLogger = new LoggingBehaviour(_logger.Object, _user.Object, _identityService.Object); + + await requestLogger.Process(new CreateTodoItemCommand { ListId = 1, Title = "title" }, new CancellationToken()); + + _identityService.Verify(i => i.GetUserNameAsync(It.IsAny()), Times.Once); + } + + [Test] + public async Task ShouldNotCallGetUserNameAsyncOnceIfUnauthenticated() + { + var requestLogger = new LoggingBehaviour(_logger.Object, _user.Object, _identityService.Object); + + await requestLogger.Process(new CreateTodoItemCommand { ListId = 1, Title = "title" }, new CancellationToken()); + + _identityService.Verify(i => i.GetUserNameAsync(It.IsAny()), Times.Never); + } +} diff --git a/tests/Application.UnitTests/Common/Exceptions/ValidationExceptionTests.cs b/tests/Application.UnitTests/Common/Exceptions/ValidationExceptionTests.cs new file mode 100644 index 0000000..c4fcbb4 --- /dev/null +++ b/tests/Application.UnitTests/Common/Exceptions/ValidationExceptionTests.cs @@ -0,0 +1,63 @@ +using Hutopy.Application.Common.Exceptions; +using FluentAssertions; +using FluentValidation.Results; +using NUnit.Framework; + +namespace Hutopy.Application.UnitTests.Common.Exceptions; + +public class ValidationExceptionTests +{ + [Test] + public void DefaultConstructorCreatesAnEmptyErrorDictionary() + { + var actual = new ValidationException().Errors; + + actual.Keys.Should().BeEquivalentTo(Array.Empty()); + } + + [Test] + public void SingleValidationFailureCreatesASingleElementErrorDictionary() + { + var failures = new List + { + new ValidationFailure("Age", "must be over 18"), + }; + + var actual = new ValidationException(failures).Errors; + + actual.Keys.Should().BeEquivalentTo(new string[] { "Age" }); + actual["Age"].Should().BeEquivalentTo(new string[] { "must be over 18" }); + } + + [Test] + public void MulitpleValidationFailureForMultiplePropertiesCreatesAMultipleElementErrorDictionaryEachWithMultipleValues() + { + var failures = new List + { + new ValidationFailure("Age", "must be 18 or older"), + new ValidationFailure("Age", "must be 25 or younger"), + new ValidationFailure("Password", "must contain at least 8 characters"), + new ValidationFailure("Password", "must contain a digit"), + new ValidationFailure("Password", "must contain upper case letter"), + new ValidationFailure("Password", "must contain lower case letter"), + }; + + var actual = new ValidationException(failures).Errors; + + actual.Keys.Should().BeEquivalentTo(new string[] { "Password", "Age" }); + + actual["Age"].Should().BeEquivalentTo(new string[] + { + "must be 25 or younger", + "must be 18 or older", + }); + + actual["Password"].Should().BeEquivalentTo(new string[] + { + "must contain lower case letter", + "must contain upper case letter", + "must contain at least 8 characters", + "must contain a digit", + }); + } +} diff --git a/tests/Application.UnitTests/Common/Mappings/MappingTests.cs b/tests/Application.UnitTests/Common/Mappings/MappingTests.cs new file mode 100644 index 0000000..70b23d6 --- /dev/null +++ b/tests/Application.UnitTests/Common/Mappings/MappingTests.cs @@ -0,0 +1,53 @@ +using System.Reflection; +using System.Runtime.CompilerServices; +using AutoMapper; +using Hutopy.Application.Common.Interfaces; +using Hutopy.Application.Common.Models; +using Hutopy.Application.TodoItems.Queries.GetTodoItemsWithPagination; +using Hutopy.Application.TodoLists.Queries.GetTodos; +using Hutopy.Domain.Entities; +using NUnit.Framework; + +namespace Hutopy.Application.UnitTests.Common.Mappings; + +public class MappingTests +{ + private readonly IConfigurationProvider _configuration; + private readonly IMapper _mapper; + + public MappingTests() + { + _configuration = new MapperConfiguration(config => + config.AddMaps(Assembly.GetAssembly(typeof(IApplicationDbContext)))); + + _mapper = _configuration.CreateMapper(); + } + + [Test] + public void ShouldHaveValidConfiguration() + { + _configuration.AssertConfigurationIsValid(); + } + + [Test] + [TestCase(typeof(TodoList), typeof(TodoListDto))] + [TestCase(typeof(TodoItem), typeof(TodoItemDto))] + [TestCase(typeof(TodoList), typeof(LookupDto))] + [TestCase(typeof(TodoItem), typeof(LookupDto))] + [TestCase(typeof(TodoItem), typeof(TodoItemBriefDto))] + public void ShouldSupportMappingFromSourceToDestination(Type source, Type destination) + { + var instance = GetInstanceOf(source); + + _mapper.Map(instance, source, destination); + } + + private object GetInstanceOf(Type type) + { + if (type.GetConstructor(Type.EmptyTypes) != null) + return Activator.CreateInstance(type)!; + + // Type without parameterless constructor + return RuntimeHelpers.GetUninitializedObject(type); + } +} diff --git a/tests/Domain.UnitTests/Domain.UnitTests.csproj b/tests/Domain.UnitTests/Domain.UnitTests.csproj new file mode 100644 index 0000000..e400eb6 --- /dev/null +++ b/tests/Domain.UnitTests/Domain.UnitTests.csproj @@ -0,0 +1,24 @@ + + + + Hutopy.Domain.UnitTests + Hutopy.Domain.UnitTests + + + + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + + + + + + + + + diff --git a/tests/Domain.UnitTests/ValueObjects/ColourTests.cs b/tests/Domain.UnitTests/ValueObjects/ColourTests.cs new file mode 100644 index 0000000..50e7785 --- /dev/null +++ b/tests/Domain.UnitTests/ValueObjects/ColourTests.cs @@ -0,0 +1,50 @@ +using Hutopy.Domain.Exceptions; +using Hutopy.Domain.ValueObjects; +using FluentAssertions; +using NUnit.Framework; + +namespace Hutopy.Domain.UnitTests.ValueObjects; + +public class ColourTests +{ + [Test] + public void ShouldReturnCorrectColourCode() + { + var code = "#FFFFFF"; + + var colour = Colour.From(code); + + colour.Code.Should().Be(code); + } + + [Test] + public void ToStringReturnsCode() + { + var colour = Colour.White; + + colour.ToString().Should().Be(colour.Code); + } + + [Test] + public void ShouldPerformImplicitConversionToColourCodeString() + { + string code = Colour.White; + + code.Should().Be("#FFFFFF"); + } + + [Test] + public void ShouldPerformExplicitConversionGivenSupportedColourCode() + { + var colour = (Colour)"#FFFFFF"; + + colour.Should().Be(Colour.White); + } + + [Test] + public void ShouldThrowUnsupportedColourExceptionGivenNotSupportedColourCode() + { + FluentActions.Invoking(() => Colour.From("##FF33CC")) + .Should().Throw(); + } +} diff --git a/tests/Infrastructure.IntegrationTests/GlobalUsings.cs b/tests/Infrastructure.IntegrationTests/GlobalUsings.cs new file mode 100644 index 0000000..cefced4 --- /dev/null +++ b/tests/Infrastructure.IntegrationTests/GlobalUsings.cs @@ -0,0 +1 @@ +global using NUnit.Framework; \ No newline at end of file diff --git a/tests/Infrastructure.IntegrationTests/Infrastructure.IntegrationTests.csproj b/tests/Infrastructure.IntegrationTests/Infrastructure.IntegrationTests.csproj new file mode 100644 index 0000000..52d7836 --- /dev/null +++ b/tests/Infrastructure.IntegrationTests/Infrastructure.IntegrationTests.csproj @@ -0,0 +1,19 @@ + + + + Hutopy.Infrastructure.IntegrationTests + Hutopy.Infrastructure.IntegrationTests + + + + + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + + +