Adds 2 units and docs for unit 3. nfr-requirements plan

This commit is contained in:
2026-06-29 22:18:37 +02:00
parent 0e01ca1e1c
commit c156107cb1
126 changed files with 15204 additions and 80199 deletions
@@ -0,0 +1,63 @@
# Code Summary — Unit 1: master-backend
## New Project: SlpModularCms.Modules.Master
| File | Description |
|------|-------------|
| `SlpModularCms.Modules.Master.csproj` | Project file; references Core; adds Microsoft.Extensions.Http.Resilience 9.6.0 |
| `Options/MasterModuleOptions.cs` | Configuration POCO: IntegrityCheckIntervalMinutes, HttpTimeoutSeconds, MasterUrl, CacheMinutes, ApiKey |
| `Data/Entities/CmsInstanceStatus.cs` | Enum: Available=0, NotAvailable=1, Inactive=2 |
| `Data/Entities/CmsInstance.cs` | EF Core entity with all domain fields including LastIntegrityCheckFailedAt |
| `Data/MasterDbContext.cs` | Per-module DbContext; table MasterCmsInstances; configured via OnModelCreating |
| `Models/CmsInstanceDto.cs` | Record DTO (excludes ApiKey); ExcludeFromCodeCoverage |
| `Models/CreateCmsInstanceRequest.cs` | Record request for POST; ExcludeFromCodeCoverage |
| `Models/UpdateStatusRequest.cs` | Record request for status update; ExcludeFromCodeCoverage |
| `Models/UpdateStatusResult.cs` | Record result with Success + SlaveContactSuccess; ExcludeFromCodeCoverage |
| `Repositories/ICmsInstanceRepository.cs` | Interface: GetAllAsync, GetActiveAsync, GetByIdAsync, AddAsync, Update, SaveChangesAsync |
| `Repositories/CmsInstanceRepository.cs` | EF Core implementation; GetActiveAsync excludes Inactive |
| `Services/IApiKeyProtector.cs` | Interface: Protect/Unprotect |
| `Services/ApiKeyProtector.cs` | Data Protection wrapper; purpose string "SlpModularCms.Master.ApiKey" |
| `Services/ISlaveApiClient.cs` | Interface: RegisterMasterAsync, PushStatusAsync, GetRegisteredMasterUrlAsync |
| `Services/SlaveApiClient.cs` | Typed HTTP client; X-Master-Api-Key header on each call; fail-open (returns false on exception) |
| `Services/MasterServiceDependencies.cs` | Record aggregating 6 CmsInstanceService dependencies; ExcludeFromCodeCoverage |
| `Services/ICmsInstanceService.cs` | Interface: GetAllAsync, AddAsync, UpdateStatusAsync, VerifyIntegrityAsync |
| `Services/CmsInstanceService.cs` | Business logic; HttpContext → config fallback for MasterUrl; never returns ApiKey in DTO |
| `BackgroundServices/IntegrityCheckBackgroundService.cs` | PeriodicTimer; per-tick IServiceScope; catches all exceptions per tick |
| `Controllers/CmsInstanceController.cs` | [Authorize(Policy="OwnerOnly")]; GET / POST / PUT /{id}/status |
| `MasterModule.cs` | IModule implementation; DI registration; db.Database.Migrate() in UseModule; ExcludeFromCodeCoverage |
| `Migrations/.gitkeep` | Placeholder; run CLI to generate migration (see below) |
## New Project: SlpModularCms.Modules.Master.Tests
| File | Description |
|------|-------------|
| `SlpModularCms.Modules.Master.Tests.csproj` | xUnit + NSubstitute + FluentAssertions + EF InMemory |
| `Repositories/CmsInstanceRepositoryTests.cs` | EF InMemory; covers all repository methods |
| `Services/ApiKeyProtectorTests.cs` | Uses EphemeralDataProtectionProvider; round-trip + invalid ciphertext tests |
| `Services/SlaveApiClientTests.cs` | FakeHttpMessageHandler; tests success/failure/exception paths + header assertion |
| `Services/CmsInstanceServiceTests.cs` | NSubstitute; covers all business logic branches including HttpContext fallback |
| `BackgroundServices/IntegrityCheckBackgroundServiceTests.cs` | PeriodicTimer integration; verifies exception isolation |
| `Controllers/CmsInstanceControllerTests.cs` | NSubstitute ICmsInstanceService; verifies all HTTP response codes |
## Modified Files
| File | Change |
|------|--------|
| `src/SlpModularCms.Api/SlpModularCms.Api.csproj` | Added ProjectReference to SlpModularCms.Modules.Master |
| `SlpModularCms.sln` | Added both new projects with GUIDs and src folder nesting |
## EF Core Migration
After building the solution, run:
```bash
dotnet ef migrations add InitialCreate --project src/SlpModularCms.Modules.Master --startup-project src/SlpModularCms.Api
```
This generates the `Migrations/` folder contents. The migration is applied automatically on startup via `db.Database.Migrate()` in `MasterModule.UseModule`.
## Notes
- `Microsoft.Extensions.Http.Resilience` version `9.6.0` — verify/update during `dotnet restore` if a newer version is available for .NET 10
- `IntegrityCheckIntervalMinutes = 0` in tests forces immediate PeriodicTimer ticks (valid for test scenarios only)
- Slave-side endpoints (`/api/v1/master/register`, `/api/v1/master/status`, `/api/v1/master/registered-url`) are implemented in Unit 2 (slave-availability-extension)
@@ -0,0 +1,191 @@
# Business Logic Model — Unit 1: master-backend
## Flow 1 — AddAsync (Add Slave CMS)
**Trigger**: `POST /api/v1/CmsInstances` (Owner only)
```mermaid
sequenceDiagram
box rgba(99,179,237,0.3) API Layer
participant Ctrl as CmsInstanceController
end
box rgba(154,230,180,0.3) Service Layer
participant Svc as CmsInstanceService
participant Repo as CmsInstanceRepository
participant Client as SlaveApiClient
end
box rgba(246,224,94,0.3) Infrastructure
participant DP as IDataProtector
participant Ctx as IHttpContextAccessor
participant Opts as MasterModuleOptions
end
box rgba(200,200,200,0.3) Persistence
participant DB as MasterDbContext
end
Ctrl->>Svc: AddAsync(request)
Note over Svc: Validate Name, Url, ApiKey not empty
Note over Svc: Validate Url starts with http or https
Svc->>DP: Protect(request.ApiKey)
DP-->>Svc: encryptedApiKey
Svc->>Repo: AddAsync(new CmsInstance)
Note over Svc,Repo: Status=Available, LastContactedAt=null
Svc->>Repo: SaveChangesAsync()
Repo->>DB: INSERT CmsInstances
Note over Svc: Determine masterUrl
Svc->>Ctx: try get base URL from HttpContext
alt HttpContext available
Ctx-->>Svc: masterUrl from request
else HttpContext unavailable
Svc->>Opts: read MasterUrl
Opts-->>Svc: configured masterUrl
end
Svc->>DP: Unprotect(encryptedApiKey)
DP-->>Svc: plainApiKey
Svc->>Client: RegisterMasterAsync(slaveUrl, plainApiKey, masterUrl)
alt Registration success
Client-->>Svc: true
Svc->>Repo: UpdateAsync (LastContactedAt = UtcNow)
Svc->>Repo: SaveChangesAsync()
Repo->>DB: UPDATE CmsInstances
else Registration failed
Client-->>Svc: false
Note over Svc: LastContactedAt stays null (owner can see)
end
Svc-->>Ctrl: CmsInstanceDto
Ctrl-->>Ctrl: return 201 Created
```
Text alternative: Controller calls service; service validates, encrypts ApiKey, persists entity, determines master URL from HttpContext or config, attempts slave registration, updates LastContactedAt on success; always returns DTO regardless of registration outcome.
---
## Flow 2 — UpdateStatusAsync (Set Slave Status)
**Trigger**: `PUT /api/v1/CmsInstances/{id}/status` (Owner only)
```mermaid
sequenceDiagram
box rgba(99,179,237,0.3) API Layer
participant Ctrl as CmsInstanceController
end
box rgba(154,230,180,0.3) Service Layer
participant Svc as CmsInstanceService
participant Repo as CmsInstanceRepository
participant Client as SlaveApiClient
end
box rgba(246,224,94,0.3) Infrastructure
participant DP as IDataProtector
end
box rgba(200,200,200,0.3) Persistence
participant DB as MasterDbContext
end
Ctrl->>Svc: UpdateStatusAsync(id, status, disableMessage)
Svc->>Repo: GetByIdAsync(id)
Repo->>DB: SELECT CmsInstances WHERE Id
DB-->>Repo: CmsInstance or null
Repo-->>Svc: entity or null
alt Entity not found
Svc-->>Ctrl: throw NotFoundException
end
Note over Svc: Validate DisableMessage required if NotAvailable
alt Validation fails
Svc-->>Ctrl: throw ValidationException
end
alt newStatus = Inactive
Svc->>Repo: UpdateAsync (Status=Inactive, DisableMessage=null)
Svc->>Repo: SaveChangesAsync()
Repo->>DB: UPDATE CmsInstances
Svc-->>Ctrl: UpdateStatusResult(Success=true, SlaveContactSuccess=true)
else newStatus = Available or NotAvailable
Svc->>Repo: UpdateAsync (Status, DisableMessage)
Svc->>Repo: SaveChangesAsync()
Repo->>DB: UPDATE CmsInstances
Svc->>DP: Unprotect(entity.ApiKey)
DP-->>Svc: plainApiKey
Svc->>Client: PushStatusAsync(slaveUrl, plainApiKey, status, disableMessage)
alt Push success
Client-->>Svc: true
Svc->>Repo: UpdateAsync (LastStatusPushedAt = UtcNow)
Svc->>Repo: SaveChangesAsync()
Repo->>DB: UPDATE CmsInstances
Svc-->>Ctrl: UpdateStatusResult(Success=true, SlaveContactSuccess=true)
else Push failed
Client-->>Svc: false
Svc-->>Ctrl: UpdateStatusResult(Success=true, SlaveContactSuccess=false)
end
end
Ctrl-->>Ctrl: return 200 OK with UpdateStatusResult
```
Text alternative: Controller calls service with id and new status; service loads entity, validates, updates DB, then for non-Inactive transitions decrypts ApiKey and pushes status to slave; returns SlaveContactSuccess=false if push fails but DB is always the authority.
---
## Flow 3 — VerifyIntegrityAsync (Background Integrity Check)
**Trigger**: `IntegrityCheckBackgroundService` periodic timer (every `IntegrityCheckIntervalMinutes`)
```mermaid
sequenceDiagram
box rgba(200,200,200,0.3) Background
participant Timer as PeriodicTimer
participant BgSvc as IntegrityCheckBackgroundService
end
box rgba(154,230,180,0.3) Service Layer
participant Svc as CmsInstanceService
participant Repo as CmsInstanceRepository
participant Client as SlaveApiClient
end
box rgba(246,224,94,0.3) Infrastructure
participant DP as IDataProtector
participant Opts as MasterModuleOptions
end
box rgba(200,200,200,0.3) Persistence
participant DB as MasterDbContext
end
Timer->>BgSvc: Tick
BgSvc->>Svc: VerifyIntegrityAsync()
Svc->>Repo: GetActiveAsync()
Repo->>DB: SELECT WHERE Status != Inactive
DB-->>Repo: list of CmsInstance
Repo-->>Svc: instances
loop for each instance
Svc->>DP: Unprotect(instance.ApiKey)
DP-->>Svc: plainApiKey
Svc->>Opts: read MasterUrl
Opts-->>Svc: masterUrl
Svc->>Client: GetRegisteredMasterUrlAsync(slaveUrl, plainApiKey)
alt Slave unreachable
Client-->>Svc: throws or returns null
Svc->>Repo: UpdateAsync (LastIntegrityCheckFailedAt = UtcNow)
Svc->>Repo: SaveChangesAsync()
Repo->>DB: UPDATE CmsInstances
else Slave reachable
Client-->>Svc: registeredMasterUrl
alt URLs match
Svc->>Repo: UpdateAsync (LastContactedAt = UtcNow, LastIntegrityCheckFailedAt = null)
Svc->>Repo: SaveChangesAsync()
Repo->>DB: UPDATE CmsInstances
else URL mismatch
Svc->>Client: RegisterMasterAsync(slaveUrl, plainApiKey, masterUrl)
alt Re-registration success
Client-->>Svc: true
Svc->>Repo: UpdateAsync (LastContactedAt = UtcNow, LastIntegrityCheckFailedAt = null)
Svc->>Repo: SaveChangesAsync()
Repo->>DB: UPDATE CmsInstances
else Re-registration failed
Client-->>Svc: false
Svc->>Repo: UpdateAsync (LastIntegrityCheckFailedAt = UtcNow)
Svc->>Repo: SaveChangesAsync()
Repo->>DB: UPDATE CmsInstances
end
end
end
end
Svc-->>BgSvc: done
```
Text alternative: Background timer triggers integrity service; for each non-Inactive slave: decrypts key, retrieves registered master URL, clears failure flag on match, re-registers on mismatch, sets LastIntegrityCheckFailedAt when slave is unreachable or re-registration fails.
@@ -0,0 +1,148 @@
# Business Rules — Unit 1: master-backend
## BR-01 — Status Update Decision Logic
```mermaid
graph TD
Start(["UpdateStatusAsync called"])
CheckExists{"Entity exists\nfor given id?"}
NotFound["Throw NotFoundException\n404 to caller"]
CheckMsg{"newStatus = NotAvailable\nAND disableMessage\nis null or empty?"}
ValidationErr["Throw ValidationException\nDisableMessage required"]
CheckInactive{"newStatus\n= Inactive?"}
SetInactive["Status = Inactive\nDisableMessage = null\nNo HTTP push\nSlaveContactSuccess = true"]
PersistStatus["Persist Status + DisableMessage\nto MasterDbContext"]
DecryptKey["Decrypt ApiKey\nvia IDataProtector"]
PushSlave["PushStatusAsync\nto slave endpoint"]
PushOk{"HTTP push\nsucceeded?"}
UpdatePushed["LastStatusPushedAt = UtcNow\nSave"]
ReturnOk["Return UpdateStatusResult\nSuccess=true\nSlaveContactSuccess=true"]
ReturnWarn["Return UpdateStatusResult\nSuccess=true\nSlaveContactSuccess=false"]
Done(["Return result to controller"])
Start --> CheckExists
CheckExists -->|"no"| NotFound
CheckExists -->|"yes"| CheckMsg
CheckMsg -->|"yes — invalid"| ValidationErr
CheckMsg -->|"no — valid"| CheckInactive
CheckInactive -->|"yes"| SetInactive --> Done
CheckInactive -->|"no"| PersistStatus --> DecryptKey --> PushSlave --> PushOk
PushOk -->|"yes"| UpdatePushed --> ReturnOk --> Done
PushOk -->|"no"| ReturnWarn --> Done
classDef decision fill:#FFC107,stroke:#F57F17,stroke-width:2px,color:#000
classDef action fill:#63b3ed,stroke:#2b6cb0,stroke-width:1px,color:#000
classDef terminal fill:#9ae6b4,stroke:#2f855a,stroke-width:2px,color:#000
classDef error fill:#FC8181,stroke:#C53030,stroke-width:2px,color:#000
class CheckExists,CheckMsg,CheckInactive,PushOk decision
class PersistStatus,DecryptKey,PushSlave,UpdatePushed,SetInactive action
class Start,Done terminal
class NotFound,ValidationErr error
```
Text alternative: Load entity (404 if missing) → validate DisableMessage required for NotAvailable → for Inactive skip push → for others persist, decrypt key, push to slave, set SlaveContactSuccess based on push result.
---
## BR-02 — Integrity Check Decision Logic
```mermaid
graph TD
Start(["VerifyIntegrityAsync\nper instance"])
GetUrl["GetRegisteredMasterUrlAsync\n(slaveUrl, plainApiKey)"]
Reachable{"Slave\nreachable?"}
SetFailed["LastIntegrityCheckFailedAt = UtcNow\nSave — continue to next"]
UrlMatch{"registeredMasterUrl\n= expected masterUrl?"}
ClearOk["LastIntegrityCheckFailedAt = null\nLastContactedAt = UtcNow\nSave"]
ReRegister["RegisterMasterAsync\n(slaveUrl, plainApiKey, masterUrl)"]
RegOk{"Re-registration\nsucceeded?"}
ClearAfterReg["LastIntegrityCheckFailedAt = null\nLastContactedAt = UtcNow\nSave"]
SetFailedReg["LastIntegrityCheckFailedAt = UtcNow\nSave"]
Next(["Next instance"])
Start --> GetUrl --> Reachable
Reachable -->|"no"| SetFailed --> Next
Reachable -->|"yes"| UrlMatch
UrlMatch -->|"match"| ClearOk --> Next
UrlMatch -->|"mismatch"| ReRegister --> RegOk
RegOk -->|"yes"| ClearAfterReg --> Next
RegOk -->|"no"| SetFailedReg --> Next
classDef decision fill:#FFC107,stroke:#F57F17,stroke-width:2px,color:#000
classDef action fill:#63b3ed,stroke:#2b6cb0,stroke-width:1px,color:#000
classDef terminal fill:#9ae6b4,stroke:#2f855a,stroke-width:2px,color:#000
classDef error fill:#FC8181,stroke:#C53030,stroke-width:2px,color:#000
class Reachable,UrlMatch,RegOk decision
class GetUrl,SetFailed,ClearOk,ReRegister,ClearAfterReg,SetFailedReg action
class Start,Next terminal
```
Text alternative: For each active slave — attempt to get its registered master URL; if unreachable set failure flag; if reachable and URL matches clear flag; if mismatch re-register; clear flag on success, set flag on failure.
---
## Validation Rules
| Rule | Field | Condition | Error |
|------|-------|-----------|-------|
| BR-VAL-01 | `Name` | Must not be null or whitespace | `Name is required` |
| BR-VAL-02 | `Url` | Must not be null or whitespace | `Url is required` |
| BR-VAL-03 | `Url` | Must start with `http://` or `https://` | `Url must be a valid absolute HTTP(S) URL` |
| BR-VAL-04 | `ApiKey` | Must not be null or whitespace (on create) | `ApiKey is required` |
| BR-VAL-05 | `DisableMessage` | Required (non-empty) when `Status = NotAvailable` | `DisableMessage is required when status is NotAvailable` |
| BR-VAL-06 | `Status` | Must be a valid `CmsInstanceStatus` enum value | `Invalid status value` |
| BR-VAL-07 | `id` (update) | CmsInstance with given id must exist | `CmsInstance not found` (404) |
---
## Status Transition Rules
| From | To | DisableMessage | HTTP Push | Notes |
|------|----|---------------|-----------|-------|
| Any | `Available` | Clear to null | Yes | Slave re-enabled |
| Any | `NotAvailable` | Required, non-empty | Yes | Slave disabled with message |
| Any | `Inactive` | Clear to null | **No** | Master stops all contact |
| `Inactive` | `Available` | Clear to null | Yes | Reactivation |
| `Inactive` | `NotAvailable` | Required, non-empty | Yes | Reactivation with disable |
---
## ApiKey Encryption Rules
| Rule | Description |
|------|-------------|
| BR-ENC-01 | `ApiKey` is encrypted via `IDataProtector` before writing to `MasterDbContext` |
| BR-ENC-02 | `ApiKey` is decrypted via `IDataProtector` immediately before each HTTP call requiring it |
| BR-ENC-03 | `ApiKey` is **never** included in `CmsInstanceDto` or any other API response |
| BR-ENC-04 | `ApiKey` is accepted in `CreateCmsInstanceRequest` on creation only; no update endpoint for ApiKey |
---
## Master URL Resolution Rules
| Context | Resolution Strategy |
|---------|-------------------|
| Controller-originated calls (Add) | Derive from `HttpContext.Request` scheme + host + (optional port) via `IHttpContextAccessor` |
| Background service calls (Integrity Check) | Read `MasterModuleOptions.MasterUrl` from configuration |
| `MasterModuleOptions.MasterUrl` is null in background context | Log a warning; skip registration/integrity for that cycle |
---
## HTTP Contact Exclusion Rules
| Rule | Description |
|------|-------------|
| BR-CONTACT-01 | Instances with `Status = Inactive` are excluded from `GetActiveAsync` and never contacted via HTTP |
| BR-CONTACT-02 | Status push is skipped when transitioning any status → `Inactive` |
| BR-CONTACT-03 | Integrity check runs only against instances where `Status != Inactive` |
---
## Background Service Rules
| Rule | Description |
|------|-------------|
| BR-BG-01 | `IntegrityCheckBackgroundService` resolves `ICmsInstanceService` via `IServiceScopeFactory` per tick (not injected directly, as service is Scoped) |
| BR-BG-02 | Each tick creates and disposes its own `IServiceScope` |
| BR-BG-03 | Exceptions within a single slave's integrity check are caught, logged, and do not abort processing for remaining slaves |
| BR-BG-04 | If `MasterModuleOptions.MasterUrl` is null or empty, the background service logs a warning and skips the entire integrity check for that cycle |
@@ -0,0 +1,132 @@
# Domain Entities — Unit 1: master-backend
## Entity Overview
```mermaid
graph TD
MasterDbCtx["MasterDbContext\n(per-module EF Core DbContext)"]
CmsInst["CmsInstance\n(aggregate root)"]
Status["CmsInstanceStatus\n(enum)"]
Opts["MasterModuleOptions\n(config POCO)"]
DP["IDataProtector\n(ApiKey encryption)"]
DTO["CmsInstanceDto\n(API response shape)"]
CreateReq["CreateCmsInstanceRequest\n(API input)"]
UpdateReq["UpdateStatusRequest\n(API input)"]
UpdateRes["UpdateStatusResult\n(API response for status update)"]
MasterDbCtx -->|"owns"| CmsInst
CmsInst -->|"has"| Status
CmsInst -->|"ApiKey encrypted via"| DP
CmsInst -->|"projected to"| DTO
CreateReq -->|"creates"| CmsInst
UpdateReq -->|"mutates status of"| CmsInst
UpdateRes -->|"returned from UpdateStatusAsync"| CmsInst
Opts -->|"IntegrityCheckIntervalMinutes"| MasterDbCtx
Opts -->|"MasterUrl fallback"| MasterDbCtx
classDef entity fill:#9ae6b4,stroke:#2f855a,stroke-width:2px,color:#000
classDef infra fill:#63b3ed,stroke:#2b6cb0,stroke-width:1px,color:#000
classDef dto fill:#FFC107,stroke:#F57F17,stroke-width:1px,color:#000
classDef config fill:#CE93D8,stroke:#6A1B9A,stroke-width:1px,color:#000
class CmsInst entity
class MasterDbCtx,DP infra
class Status,DTO,CreateReq,UpdateReq,UpdateRes dto
class Opts config
```
Text alternative: MasterDbContext owns CmsInstance; CmsInstance has a Status enum and its ApiKey is encrypted via IDataProtector; request/response shapes map to and from CmsInstance.
---
## CmsInstance
**Table**: `CmsInstances` (owned by `MasterDbContext`, migrations in `SlpModularCms.Modules.Master`)
| Field | Type | Nullable | Notes |
|-------|------|----------|-------|
| `Id` | `Guid` | No | Primary key |
| `Name` | `string` | No | Friendly display name; required |
| `Url` | `string` | No | Base URL of slave CMS API; must start with `http://` or `https://` |
| `ApiKey` | `string` | No | Encrypted via ASP.NET Core Data Protection before storage; decrypted before HTTP calls |
| `Status` | `CmsInstanceStatus` | No | Default: `Available` on creation |
| `DisableMessage` | `string?` | Yes | Required when `Status = NotAvailable`; null otherwise |
| `LastContactedAt` | `DateTimeOffset?` | Yes | Null = never successfully contacted; set on successful registration or integrity check |
| `LastStatusPushedAt` | `DateTimeOffset?` | Yes | Null = status never successfully pushed; set after successful `PushStatusAsync` |
| `LastIntegrityCheckFailedAt` | `DateTimeOffset?` | Yes | Null = no pending failure; set when integrity check cannot reach slave or re-registration fails; cleared on next successful contact |
---
## CmsInstanceStatus
```csharp
public enum CmsInstanceStatus
{
Available = 0,
NotAvailable = 1,
Inactive = 2,
}
```
| Value | Meaning | Master Contacts Slave? |
|-------|---------|----------------------|
| `Available` | Slave is enabled; normal operation | Yes (status push + integrity checks) |
| `NotAvailable` | Slave is disabled; `DisableMessage` served to end-users | Yes (status push + integrity checks) |
| `Inactive` | Soft-removed; greyed out in UI | **No** — all HTTP contact is halted |
---
## MasterModuleOptions
**Config section**: `"MasterModule"` in `appsettings.json`
| Property | Type | Default | Side | Notes |
|----------|------|---------|------|-------|
| `IntegrityCheckIntervalMinutes` | `int` | `60` | Master | Interval for `IntegrityCheckBackgroundService` |
| `MasterUrl` | `string?` | `null` | Master | Fallback public URL of this master CMS; used by background service when `HttpContext` is unavailable |
| `CacheMinutes` | `int` | `60` | Slave | Slave pull cache interval (used by Unit 2) |
| `ApiKey` | `string?` | `null` | Slave | Slave API key for validating incoming master requests (used by Unit 2) |
---
## CmsInstanceDto (API Response)
**Rule**: `ApiKey` is **never** included (NFR-MASTER-03).
| Property | Type | Notes |
|----------|------|-------|
| `Id` | `Guid` | |
| `Name` | `string` | |
| `Url` | `string` | |
| `Status` | `string` | Serialized as string (`"Available"` / `"NotAvailable"` / `"Inactive"`) |
| `DisableMessage` | `string?` | |
| `LastContactedAt` | `DateTimeOffset?` | |
| `LastStatusPushedAt` | `DateTimeOffset?` | |
| `LastIntegrityCheckFailedAt` | `DateTimeOffset?` | Visible in UI so owner knows which slaves have pending check failures |
---
## CreateCmsInstanceRequest (API Input)
| Property | Type | Validation |
|----------|------|-----------|
| `Name` | `string` | Required, non-empty |
| `Url` | `string` | Required; must start with `http://` or `https://` |
| `ApiKey` | `string` | Required, non-empty |
---
## UpdateStatusRequest (API Input)
| Property | Type | Validation |
|----------|------|-----------|
| `Status` | `CmsInstanceStatus` | Required; must be valid enum value |
| `DisableMessage` | `string?` | Required and non-empty when `Status = NotAvailable`; ignored otherwise |
---
## UpdateStatusResult (Service Return / API Response)
| Property | Type | Notes |
|----------|------|-------|
| `Success` | `bool` | Always `true` when status persisted to DB (DB is the authority) |
| `SlaveContactSuccess` | `bool` | `true` if HTTP push to slave succeeded; `false` if push failed (slave unreachable); not applicable for `Inactive` transitions (returns `true`) |
@@ -0,0 +1,128 @@
# Logical Components — Unit 1: master-backend
## Full Component Wiring Diagram
```mermaid
graph TD
subgraph ServiceLayer["Service Layer"]
CmsService["CmsInstanceService"]
Deps["MasterServiceDependencies\n(constructor record)"]
Repo["ICmsInstanceRepository"]
SlaveClientIface["ISlaveApiClient"]
ApiKeyProt["IApiKeyProtector"]
HttpCtxAcc["IHttpContextAccessor"]
Logger["ILogger"]
Opts["MasterModuleOptions\n(via IOptions)"]
end
subgraph SecurityLayer["Security Layer"]
ApiKeyProtImpl["ApiKeyProtector"]
DataProt["IDataProtectionProvider\n(ASP.NET Core)"]
Purpose["Purpose string\nSlpModularCms.Master.ApiKey"]
end
subgraph HttpLayer["HTTP + Resilience Layer"]
SlaveClientImpl["SlaveApiClient"]
PollyPipeline["Polly ResiliencePipeline\nRetry x2 + Timeout"]
HttpClientInst["HttpClient\n(IHttpClientFactory)"]
end
subgraph BackgroundLayer["Background Service"]
BgSvc["IntegrityCheckBackgroundService"]
ScopeFactory["IServiceScopeFactory"]
Scope["IServiceScope\n(per tick)"]
PeriodicT["PeriodicTimer\n(IntegrityCheckIntervalMinutes)"]
end
subgraph DataLayer["Data Layer"]
RepoImpl["CmsInstanceRepository"]
DbCtx["MasterDbContext"]
Table["CmsInstances table"]
end
CmsService --> Deps
Deps --> Repo
Deps --> SlaveClientIface
Deps --> ApiKeyProt
Deps --> HttpCtxAcc
Deps --> Logger
Deps --> Opts
ApiKeyProt --> ApiKeyProtImpl
ApiKeyProtImpl --> DataProt
DataProt --> Purpose
SlaveClientIface --> SlaveClientImpl
SlaveClientImpl --> PollyPipeline
PollyPipeline --> HttpClientInst
Repo --> RepoImpl
RepoImpl --> DbCtx
DbCtx --> Table
BgSvc --> PeriodicT
BgSvc --> ScopeFactory
ScopeFactory --> Scope
Scope --> CmsService
classDef service fill:#9ae6b4,stroke:#2f855a,stroke-width:2px,color:#000
classDef security fill:#FC8181,stroke:#C53030,stroke-width:1px,color:#000
classDef http fill:#63b3ed,stroke:#2b6cb0,stroke-width:1px,color:#000
classDef background fill:#CE93D8,stroke:#6A1B9A,stroke-width:1px,color:#000
classDef data fill:#FFC107,stroke:#F57F17,stroke-width:1px,color:#000
classDef record fill:#B0BEC5,stroke:#546E7A,stroke-width:1px,color:#000
class CmsService,Repo,SlaveClientIface,ApiKeyProt service
class Deps record
class ApiKeyProtImpl,DataProt,Purpose security
class SlaveClientImpl,PollyPipeline,HttpClientInst,HttpCtxAcc http
class BgSvc,ScopeFactory,Scope,PeriodicT background
class RepoImpl,DbCtx,Table,Logger,Opts data
```
Text alternative: CmsInstanceService receives all dependencies via MasterServiceDependencies record; IApiKeyProtector wraps Data Protection; ISlaveApiClient wraps SlaveApiClient backed by Polly pipeline; ICmsInstanceRepository wraps MasterDbContext; IntegrityCheckBackgroundService creates a new IServiceScope per PeriodicTimer tick to resolve CmsInstanceService.
---
## Component Responsibility Summary
| Component | Type | NFR Pattern |
|-----------|------|------------|
| `MasterServiceDependencies` | Record | Constructor aggregation (reduces constructor arity) |
| `IApiKeyProtector` / `ApiKeyProtector` | Interface + Singleton | Security — Data Protection wrapper; mock-friendly |
| `SlaveApiClient` | Typed HTTP client | Resilience — Polly retry + timeout applied via `AddResilienceHandler` |
| `IntegrityCheckBackgroundService` | Singleton `BackgroundService` | Reliability — per-tick `IServiceScope`; exception isolation per slave |
| `MasterDbContext` | EF Core DbContext | Maintainability — per-module migrations; own connection |
| `CmsInstanceService` | Scoped service | Orchestration — resolved via `IServiceScope` by background service |
---
## DI Registration Order (in `MasterModule.RegisterServices`)
```
1. services.AddDataProtection()
2. services.AddSingleton<IApiKeyProtector, ApiKeyProtector>()
3. services.Configure<MasterModuleOptions>(config.GetSection("MasterModule"))
4. services.AddDbContext<MasterDbContext>(...)
5. services.AddScoped<ICmsInstanceRepository, CmsInstanceRepository>()
6. services.AddScoped<MasterServiceDependencies>()
7. services.AddScoped<ICmsInstanceService, CmsInstanceService>()
8. services.AddHttpClient<ISlaveApiClient, SlaveApiClient>()
.AddResilienceHandler("slave-resilience", ...)
9. services.AddHostedService<IntegrityCheckBackgroundService>()
10. services.AddHttpContextAccessor() (if not already registered by host)
```
---
## NFR Coverage Traceability
| NFR | Pattern Applied | Component |
|-----|----------------|-----------|
| Fail-open (NFR-MASTER-01) | `SlaveApiClient` catches failures, returns `false`; service continues | `SlaveApiClient`, `CmsInstanceService` |
| API key security (NFR-MASTER-03) | `IApiKeyProtector` wraps Data Protection; never returns key in DTO | `ApiKeyProtector`, `CmsInstanceDto` mapping |
| Configurable interval (NFR-MASTER-04) | `PeriodicTimer` reads `MasterModuleOptions.IntegrityCheckIntervalMinutes` | `IntegrityCheckBackgroundService` |
| ≥80% test coverage (NFR-MASTER-05) | All service/repository/client classes have interfaces; `MasterServiceDependencies` simplifies test setup | All interfaces |
| Per-module migrations (NFR-MASTER-06) | `MasterDbContext` with own migration assembly; applied in `UseModule` | `MasterDbContext`, `MasterModule` |
| Retry resilience (Q2) | Polly exponential backoff on `IHttpClientBuilder` | `SlaveApiClient` registration |
| Timeout (Q1) | Polly `AddTimeout` per attempt, driven by `HttpTimeoutSeconds` | `SlaveApiClient` registration |
| Logging levels (Q5) | `Error` for status push failures; `Warning` for integrity check failures | `CmsInstanceService`, `IntegrityCheckBackgroundService` |
@@ -0,0 +1,208 @@
# NFR Design Patterns — Unit 1: master-backend
## Pattern 1 — Resilience: Polly Pipeline via `AddResilienceHandler`
**NFR**: Exponential backoff (3 attempts), per-attempt timeout (`HttpTimeoutSeconds`)
**Pattern**: Single shared resilience pipeline registered on the `IHttpClientBuilder` for `SlaveApiClient`. All HTTP calls from `SlaveApiClient` pass through the pipeline automatically — no per-method boilerplate.
**Pipeline composition** (outer → inner execution order):
1. **Retry**`AddRetry` with exponential backoff; max 2 retries (3 total attempts); base delay 1s → 2s with jitter; retries on `HttpRequestException` and non-2xx responses
2. **Timeout**`AddTimeout` with `TimeSpan.FromSeconds(MasterModuleOptions.HttpTimeoutSeconds)`; applied per attempt (not total)
**Registration** (in `MasterModule.RegisterServices`):
```csharp
services.AddHttpClient<ISlaveApiClient, SlaveApiClient>()
.AddResilienceHandler("slave-resilience", (builder, ctx) =>
{
var opts = ctx.ServiceProvider
.GetRequiredService<IOptions<MasterModuleOptions>>().Value;
builder.AddRetry(new HttpRetryStrategyOptions
{
MaxRetryAttempts = 2,
Delay = TimeSpan.FromSeconds(1),
BackoffType = DelayBackoffType.Exponential,
UseJitter = true,
ShouldHandle = args => ValueTask.FromResult(
args.Outcome.Exception is HttpRequestException ||
(args.Outcome.Result?.IsSuccessStatusCode == false))
});
builder.AddTimeout(TimeSpan.FromSeconds(opts.HttpTimeoutSeconds));
});
```
**Retry flow**:
```mermaid
graph TD
Call["SlaveApiClient HTTP call"]
Attempt["Execute HTTP request\n(with per-attempt timeout)"]
Success{"Response\nsuccessful?"}
ReturnOk["Return result"]
MaxReached{"Max attempts\n(3) reached?"}
Backoff["Wait exponential delay\n1s or 2s plus jitter"]
ReturnFail["Return false\nor throw on final attempt"]
Call --> Attempt --> Success
Success -->|"yes"| ReturnOk
Success -->|"no"| MaxReached
MaxReached -->|"yes"| ReturnFail
MaxReached -->|"no"| Backoff --> Attempt
classDef decision fill:#FFC107,stroke:#F57F17,stroke-width:2px,color:#000
classDef action fill:#63b3ed,stroke:#2b6cb0,stroke-width:1px,color:#000
classDef terminal fill:#9ae6b4,stroke:#2f855a,stroke-width:2px,color:#000
classDef fail fill:#FC8181,stroke:#C53030,stroke-width:2px,color:#000
class Success,MaxReached decision
class Call,Attempt,Backoff action
class ReturnOk terminal
class ReturnFail fail
```
Text alternative: HTTP call enters pipeline; per-attempt timeout applies; on failure checks if max attempts reached; if not waits exponential delay and retries; after 3 total failures returns false.
---
## Pattern 2 — Security: `IApiKeyProtector` Wrapper
**NFR**: ApiKey encrypted at rest; decrypted only for HTTP calls; never exposed in responses
**Pattern**: Thin wrapper interface over ASP.NET Core Data Protection. Keeps `CmsInstanceService` independent of Data Protection internals and makes unit tests trivially simple (mock returns plain strings).
**Interface**:
```csharp
public interface IApiKeyProtector
{
string Protect(string plainApiKey);
string Unprotect(string encryptedApiKey);
}
```
**Implementation**:
```csharp
public class ApiKeyProtector : IApiKeyProtector
{
private readonly IDataProtector _protector;
public ApiKeyProtector(IDataProtectionProvider provider)
{
_protector = provider.CreateProtector("SlpModularCms.Master.ApiKey");
}
public string Protect(string plainApiKey) => _protector.Protect(plainApiKey);
public string Unprotect(string encrypted) => _protector.Unprotect(encrypted);
}
```
**Registration** (in `MasterModule.RegisterServices`):
```csharp
services.AddDataProtection();
services.AddSingleton<IApiKeyProtector, ApiKeyProtector>();
```
**Usage in tests**:
```csharp
var protector = new Mock<IApiKeyProtector>();
protector.Setup(p => p.Protect(It.IsAny<string>())).Returns((string s) => $"enc:{s}");
protector.Setup(p => p.Unprotect(It.IsAny<string>())).Returns((string s) => s.Replace("enc:", ""));
```
---
## Pattern 3 — Constructor Aggregation: `MasterServiceDependencies`
**Rationale**: `CmsInstanceService` requires 6 dependencies. Wrapping them in a record removes constructor noise and groups related parameters semantically.
**Record definition**:
```csharp
public record MasterServiceDependencies(
ICmsInstanceRepository Repository,
ISlaveApiClient SlaveClient,
IApiKeyProtector ApiKeyProtector,
IOptions<MasterModuleOptions> Options,
IHttpContextAccessor HttpContextAccessor,
ILogger<CmsInstanceService> Logger
);
```
**Registration** (framework resolves all fields automatically):
```csharp
services.AddScoped<MasterServiceDependencies>();
services.AddScoped<ICmsInstanceService, CmsInstanceService>();
```
**`CmsInstanceService` constructor**:
```csharp
public CmsInstanceService(MasterServiceDependencies deps)
{
_deps = deps;
}
```
**Test construction** (explicit, no DI container needed):
```csharp
var deps = new MasterServiceDependencies(
mockRepo.Object,
mockSlaveClient.Object,
mockProtector.Object,
Options.Create(new MasterModuleOptions()),
mockHttpContextAccessor.Object,
NullLogger<CmsInstanceService>.Instance
);
var svc = new CmsInstanceService(deps);
```
---
## Pattern 4 — Background Service Scope Isolation
**NFR**: `ICmsInstanceService` is Scoped; `IntegrityCheckBackgroundService` is Singleton
**Pattern**: Create and dispose a dedicated `IServiceScope` per tick. No singleton scope leakage.
```csharp
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
using var timer = new PeriodicTimer(
TimeSpan.FromMinutes(_options.Value.IntegrityCheckIntervalMinutes));
while (await timer.WaitForNextTickAsync(stoppingToken))
{
await using var scope = _scopeFactory.CreateAsyncScope();
try
{
var svc = scope.ServiceProvider
.GetRequiredService<ICmsInstanceService>();
await svc.VerifyIntegrityAsync();
}
catch (Exception ex)
{
_logger.LogError(ex, "Unexpected error during integrity check tick");
}
}
}
```
---
## Pattern 5 — Structured Logging
**Pattern**: Log with structured fields; never log the raw `ApiKey` value.
| Scenario | Level | Fields |
|----------|-------|--------|
| Status push failed | `Error` | `{InstanceId}`, `{SlaveUrl}`, `{Exception}` |
| Integrity check: slave unreachable | `Warning` | `{InstanceId}`, `{SlaveUrl}` |
| Integrity check: URL mismatch | `Warning` | `{InstanceId}`, `{SlaveUrl}`, `{ExpectedUrl}`, `{RegisteredUrl}` |
| Integrity check: re-registration ok | `Information` | `{InstanceId}`, `{SlaveUrl}` |
| Integrity check: re-registration failed | `Warning` | `{InstanceId}`, `{SlaveUrl}` |
| ApiKey decryption failure | `Error` | `{InstanceId}` — do NOT log key material |
| Tick started | `Debug` | `{ActiveInstanceCount}` |
**Example**:
```csharp
_logger.LogWarning(
"Slave {InstanceId} at {SlaveUrl} is unreachable during integrity check",
instance.Id, instance.Url);
```
@@ -0,0 +1,77 @@
# NFR Requirements — Unit 1: master-backend
## Performance
| Requirement | Specification | Source |
|-------------|--------------|--------|
| HTTP timeout for slave calls | Configurable via `MasterModuleOptions.HttpTimeoutSeconds`; default **10 seconds** | Q1 |
| HTTP retry budget | Maximum 3 attempts (initial + 2 retries) with exponential backoff (1s, 2s); per-attempt timeout applies | Q2 |
| Background service interval | Configurable via `MasterModuleOptions.IntegrityCheckIntervalMinutes`; default 60 minutes | NFR-MASTER-04 |
| Controller endpoint latency | No explicit SLA; bounded by HTTP timeout × retry attempts (worst case ~33s for a single unresponsive slave during Add/UpdateStatus) | derived |
---
## Security
| Requirement | Specification | Source |
|-------------|--------------|--------|
| ApiKey at-rest encryption | Encrypted with ASP.NET Core Data Protection before writing to DB; decrypted immediately before HTTP calls | Q3 (+ functional design) |
| Data Protection key storage | **Default file system** (platform default); machine-bound; acceptable for single-instance deployment | Q3 |
| ApiKey exposure | Never included in `CmsInstanceDto` or any API response; `[JsonIgnore]` or explicit DTO mapping | NFR-MASTER-03 |
| Endpoint authorization | All `CmsInstanceController` actions require `[Authorize(Policy = "OwnerOnly")]` | FR-MASTER-10 |
| Internal slave endpoint auth | `POST /api/internal/master/register` validated via `X-Master-Api-Key` header (Unit 2 concern) | FR-MASTER-03 |
---
## Reliability
| Requirement | Specification | Source |
|-------------|--------------|--------|
| Fail-open on slave unreachability | Status push failure returns `SlaveContactSuccess = false` but does not roll back DB change; integrity check sets `LastIntegrityCheckFailedAt` and continues | NFR-MASTER-01 |
| Retry policy | Exponential backoff: attempt 1 (immediate), attempt 2 (+1s delay), attempt 3 (+2s delay); implemented via Polly `ResiliencePipeline` | Q2 |
| Background service isolation | Exceptions per slave instance are caught, logged, and do not abort the full integrity check batch | BR-BG-03 |
| Background service scope | `IServiceScopeFactory` used per tick to resolve scoped `ICmsInstanceService`; scope disposed after each tick | BR-BG-01/02 |
---
## Testability
| Requirement | Specification | Source |
|-------------|--------------|--------|
| Minimum test coverage | ≥ 80% line/branch coverage for `SlpModularCms.Modules.Master` (excluding items below) | NFR-MASTER-05 |
| Coverage exclusions | Apply `[ExcludeFromCodeCoverage]` to: `MasterModule.cs` (IModule boilerplate), EF Core migration files, plain DTO/record classes with no logic | Q4 |
| Test project | `SlpModularCms.Modules.Master.Tests` — separate project; mirrors production project structure | Unit decomposition decision |
| Key test targets | `CmsInstanceService`, `SlaveApiClient`, `IntegrityCheckBackgroundService`, `CmsInstanceController` | NFR-MASTER-05 |
| Interface-driven design | `ICmsInstanceRepository`, `ICmsInstanceService`, `ISlaveApiClient` interfaces required to enable unit test mocking | derived |
---
## Maintainability
| Requirement | Specification | Source |
|-------------|--------------|--------|
| Log level — integrity check failures | **Warning** — slave unreachability during background checks is expected; does not require immediate attention | Q5 |
| Log level — status push failures | **Error** — owner-triggered action failed to reach slave; requires visibility | Q5 |
| Log level — re-registration on mismatch | **Information** — expected recovery action | derived |
| Log level — background service tick | **Debug** — high frequency; only visible when debugging | derived |
| Structured logging | Use `ILogger<T>` with structured message templates; include `slaveUrl` and `instanceId` in log scope | derived |
---
## Updated `MasterModuleOptions` Fields
The following field is added as a result of Q1:
| Property | Type | Default | Notes |
|----------|------|---------|-------|
| `HttpTimeoutSeconds` | `int` | `10` | Timeout applied to each individual HTTP attempt in `SlaveApiClient` |
Full updated options shape:
| Property | Type | Default | Side |
|----------|------|---------|------|
| `IntegrityCheckIntervalMinutes` | `int` | `60` | Master |
| `HttpTimeoutSeconds` | `int` | `10` | Master |
| `MasterUrl` | `string?` | `null` | Master |
| `CacheMinutes` | `int` | `60` | Slave |
| `ApiKey` | `string?` | `null` | Slave |
@@ -0,0 +1,112 @@
# Tech Stack Decisions — Unit 1: master-backend
## HTTP Client & Resilience
### Decision: Typed HTTP Client via `IHttpClientFactory` + Polly
| Aspect | Decision | Rationale |
|--------|----------|-----------|
| HTTP client abstraction | `ISlaveApiClient` / `SlaveApiClient` typed client | Testable via mock injection; clean contract boundary |
| Client registration | `services.AddHttpClient<ISlaveApiClient, SlaveApiClient>()` | Framework manages `HttpClient` lifetime and connection pooling |
| Retry policy | **Polly** `ResiliencePipelineBuilder` with `AddRetry` | Industry standard .NET resilience library; integrates natively with `IHttpClientFactory` via `AddResilienceHandler` |
| Retry configuration | 3 total attempts; delays: 1s → 2s (exponential); jitter optional | Bounded worst-case latency; exponential reduces thundering herd on widespread slave outages |
| Per-attempt timeout | `MasterModuleOptions.HttpTimeoutSeconds` (default 10s) | Configurable per-environment; keeps controller responses bounded |
**NuGet package required**: `Microsoft.Extensions.Http.Resilience` (includes Polly integration)
**Registration pattern**:
```csharp
services.AddHttpClient<ISlaveApiClient, SlaveApiClient>()
.AddResilienceHandler("slave-retry", builder =>
{
builder.AddRetry(new HttpRetryStrategyOptions
{
MaxRetryAttempts = 2,
Delay = TimeSpan.FromSeconds(1),
BackoffType = DelayBackoffType.Exponential,
UseJitter = true,
});
builder.AddTimeout(TimeSpan.FromSeconds(options.HttpTimeoutSeconds));
});
```
---
## Data Protection
### Decision: Default ASP.NET Core Data Protection (file system)
| Aspect | Decision | Rationale |
|--------|----------|-----------|
| Key storage | Default file system (no explicit `PersistKeysTo*` call) | Zero configuration; acceptable for single-instance; owner manages production key persistence |
| Purpose string | `"SlpModularCms.Master.ApiKey"` | Scoped protection; prevents cross-purpose decryption |
| Registration | `services.AddDataProtection()` (already called by framework if not explicitly called) | No extra setup needed beyond injecting `IDataProtectionProvider` |
**Production note** (to be included in Unit 4 README update): For containerized or multi-instance deployments, configure a persistent key ring (e.g., `PersistKeysToDbContext`, `PersistKeysToAzureBlobStorage`). Without it, restarting the container causes all encrypted `ApiKey` values to become unreadable.
---
## Background Service
### Decision: .NET `BackgroundService` + `PeriodicTimer`
| Aspect | Decision | Rationale |
|--------|----------|-----------|
| Base class | `BackgroundService` | Built-in .NET hosted service; lifecycle managed by `IHostApplicationLifetime` |
| Timer mechanism | `PeriodicTimer` | Allocates less than `Timer`; await-friendly; cancellation-aware |
| Scope management | `IServiceScopeFactory.CreateScope()` per tick | Required because `ICmsInstanceService` is Scoped; prevents captive dependency |
| Exception handling | `try/catch` around entire tick body; log `Error` and continue | Prevents background service crash on unexpected errors |
---
## Logging
### Decision: `ILogger<T>` structured logging
| Scenario | Log Level | Structured Fields |
|----------|-----------|------------------|
| Status push failed (slave unreachable) | `Error` | `instanceId`, `slaveUrl`, `exception` |
| Integrity check: slave unreachable | `Warning` | `instanceId`, `slaveUrl` |
| Integrity check: URL mismatch detected | `Warning` | `instanceId`, `slaveUrl`, `expectedUrl`, `registeredUrl` |
| Integrity check: re-registration succeeded | `Information` | `instanceId`, `slaveUrl` |
| Integrity check: re-registration failed | `Warning` | `instanceId`, `slaveUrl` |
| Background service tick started | `Debug` | `instanceCount` |
| ApiKey decryption failed | `Error` | `instanceId` (do NOT log the key itself) |
---
## EF Core / Database
### Decision: Per-module `MasterDbContext` with own migrations
| Aspect | Decision |
|--------|----------|
| DbContext class | `MasterDbContext : DbContext` in `SlpModularCms.Modules.Master` |
| Migration assembly | `SlpModularCms.Modules.Master` (same project) |
| Migration application | `app.ApplicationServices.CreateScope()``MasterDbContext.Database.MigrateAsync()` in `MasterModule.UseModule(IApplicationBuilder)` |
| Tables owned | `CmsInstances`, `DataProtectionKeys` (if needed in future) |
| Connection string | Reuses the same connection string as `ApplicationDbContext` (from `ConnectionStrings:DefaultConnection`) |
---
## Test Framework
### Decision: xUnit + Moq (matching existing test projects)
| Aspect | Decision | Rationale |
|--------|----------|-----------|
| Test framework | xUnit | Matches existing `Availability.Tests` project |
| Mocking | Moq | Matches existing test projects |
| Coverage tool | coverlet (via `.runsettings` or `dotnet test --collect`) | Already in use in existing test projects |
| `[ExcludeFromCodeCoverage]` targets | `MasterModule`, EF Core migration files, DTO records | Q4 decision |
| HTTP testing | Mock `ISlaveApiClient` via Moq | Typed client interface enables clean mocking without `HttpMessageHandler` fakes |
---
## Summary of New Dependencies
| Package | Version | Purpose |
|---------|---------|---------|
| `Microsoft.Extensions.Http.Resilience` | Latest stable | Polly integration for `IHttpClientFactory` retry policies |
All other dependencies (EF Core, ASP.NET Core, xUnit, Moq) are already present in the solution.