From 967bb4420a4780e0e06bf60154bab2785bf88900 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 5 Apr 2026 13:45:35 +0000 Subject: [PATCH 01/14] Fix 4 tutorials with code snippets mismatched against actual source code Agent-Logs-Url: https://github.com/devstress/My3DLearning/sessions/98b2f72d-66d3-45fc-b60f-6b96898805a7 Co-authored-by: devstress <30769729+devstress@users.noreply.github.com> --- .../tutorials/26-message-replay.md | 14 ++++---- .../tutorials/31-event-sourcing.md | 2 +- .../tutorials/35-connector-sftp.md | 2 +- .../tutorials/38-opentelemetry.md | 34 +++++++++++++++---- 4 files changed, 37 insertions(+), 15 deletions(-) diff --git a/EnterpriseIntegrationPlatform/tutorials/26-message-replay.md b/EnterpriseIntegrationPlatform/tutorials/26-message-replay.md index 8550042..842ad39 100644 --- a/EnterpriseIntegrationPlatform/tutorials/26-message-replay.md +++ b/EnterpriseIntegrationPlatform/tutorials/26-message-replay.md @@ -81,12 +81,14 @@ public record ReplayFilter ```csharp // src/Processing.Replay/ReplayResult.cs -public sealed record ReplayResult( - int ReplayedCount, - int SkippedCount, - int FailedCount, - DateTimeOffset StartedAt, - DateTimeOffset CompletedAt); +public record ReplayResult +{ + public required int ReplayedCount { get; init; } + public required int SkippedCount { get; init; } + public required int FailedCount { get; init; } + public required DateTimeOffset StartedAt { get; init; } + public required DateTimeOffset CompletedAt { get; init; } +} ``` Every replayed message receives a `ReplayId` header (a GUID) linking it back to the replay operation. This separates replayed traffic from live traffic in dashboards and audit logs. diff --git a/EnterpriseIntegrationPlatform/tutorials/31-event-sourcing.md b/EnterpriseIntegrationPlatform/tutorials/31-event-sourcing.md index 63fd101..5fc5891 100644 --- a/EnterpriseIntegrationPlatform/tutorials/31-event-sourcing.md +++ b/EnterpriseIntegrationPlatform/tutorials/31-event-sourcing.md @@ -107,7 +107,7 @@ public static class TemporalQuery DateTimeOffset pointInTime, TState initialState, int maxEventsPerRead = 1000, - CancellationToken cancellationToken = default) where TState : notnull; + CancellationToken cancellationToken = default); } ``` diff --git a/EnterpriseIntegrationPlatform/tutorials/35-connector-sftp.md b/EnterpriseIntegrationPlatform/tutorials/35-connector-sftp.md index f7c16f2..402feed 100644 --- a/EnterpriseIntegrationPlatform/tutorials/35-connector-sftp.md +++ b/EnterpriseIntegrationPlatform/tutorials/35-connector-sftp.md @@ -75,7 +75,7 @@ public sealed class SftpConnectorOptions ## Scalability Dimension -SFTP connections are **expensive** — each connection requires a TCP handshake and SSH negotiation. The connector pools connections per host and reuses them across requests. Multiple consumer replicas can upload concurrently, but the remote server's connection limit must be respected. Using unique filenames (e.g. based on `MessageId`) avoids filename collisions across replicas. +SFTP connections are **expensive** — each connection requires a TCP handshake and SSH negotiation. Multiple consumer replicas can upload concurrently, but the remote server's connection limit must be respected. Production deployments should implement connection pooling at the infrastructure level or limit the number of concurrent SFTP consumers. Using unique filenames (e.g. based on `MessageId`) avoids filename collisions across replicas. --- diff --git a/EnterpriseIntegrationPlatform/tutorials/38-opentelemetry.md b/EnterpriseIntegrationPlatform/tutorials/38-opentelemetry.md index f7c61a6..bccd6c4 100644 --- a/EnterpriseIntegrationPlatform/tutorials/38-opentelemetry.md +++ b/EnterpriseIntegrationPlatform/tutorials/38-opentelemetry.md @@ -87,13 +87,33 @@ Each pipeline stage starts an `Activity` (OpenTelemetry span): // src/Observability/PlatformMeters.cs public static class PlatformMeters { - public static readonly Counter MessagesReceived = /* "eip.messages.received" */; - public static readonly Counter MessagesProcessed = /* "eip.messages.processed" */; - public static readonly Counter MessagesFailed = /* "eip.messages.failed" */; - public static readonly Counter MessagesDeadLettered = /* "eip.messages.dead_lettered" */; - public static readonly Counter MessagesRetried = /* "eip.messages.retried" */; - public static readonly Histogram ProcessingDuration = /* "eip.messages.processing_duration" (ms) */; - public static readonly UpDownCounter MessagesInFlight = /* "eip.messages.in_flight" */; + public static readonly Counter MessagesReceived = + DiagnosticsConfig.Meter.CreateCounter("eip.messages.received", + unit: "{message}", description: "Total number of messages received by the platform."); + + public static readonly Counter MessagesProcessed = + DiagnosticsConfig.Meter.CreateCounter("eip.messages.processed", + unit: "{message}", description: "Total number of messages processed successfully."); + + public static readonly Counter MessagesFailed = + DiagnosticsConfig.Meter.CreateCounter("eip.messages.failed", + unit: "{message}", description: "Total number of messages that failed processing."); + + public static readonly Counter MessagesDeadLettered = + DiagnosticsConfig.Meter.CreateCounter("eip.messages.dead_lettered", + unit: "{message}", description: "Total number of messages sent to the dead-letter store."); + + public static readonly Counter MessagesRetried = + DiagnosticsConfig.Meter.CreateCounter("eip.messages.retried", + unit: "{message}", description: "Total number of message retry attempts."); + + public static readonly Histogram ProcessingDuration = + DiagnosticsConfig.Meter.CreateHistogram("eip.messages.processing_duration", + unit: "ms", description: "Duration of end-to-end message processing in milliseconds."); + + public static readonly UpDownCounter MessagesInFlight = + DiagnosticsConfig.Meter.CreateUpDownCounter("eip.messages.in_flight", + unit: "{message}", description: "Number of messages currently in-flight."); // Static helper methods for recording with consistent tags: public static void RecordReceived(string messageType, string source); From 6d62de25ef4e32b62e29d1d67164d012a5fc71a6 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 5 Apr 2026 14:10:33 +0000 Subject: [PATCH 02/14] Add Phase 22: 13 implementation chunks for unfulfilled tutorial promises Agent-Logs-Url: https://github.com/devstress/My3DLearning/sessions/e5c7edf8-8839-4170-a78b-d706c68f374c Co-authored-by: devstress <30769729+devstress@users.noreply.github.com> --- .../rules/milestones.md | 172 ++++++++++++++++-- .../tutorials/35-connector-sftp.md | 2 +- 2 files changed, 158 insertions(+), 16 deletions(-) diff --git a/EnterpriseIntegrationPlatform/rules/milestones.md b/EnterpriseIntegrationPlatform/rules/milestones.md index b8f580d..ef23e16 100644 --- a/EnterpriseIntegrationPlatform/rules/milestones.md +++ b/EnterpriseIntegrationPlatform/rules/milestones.md @@ -22,7 +22,7 @@ ## Completed Phases -✅ Phases 1–18 complete — see `rules/completion-log.md` for full history. +✅ Phases 1–21 complete — see `rules/completion-log.md` for full history. **Current stats:** 1,472 UnitTests + 58 Contract + 29 Workflow + 17 Integration + 10 Load + 19 Vitest = **1,605 total tests**. 48 src projects. @@ -30,26 +30,168 @@ ### Phase 19 — Tutorial Audit as New Developer (Round 6) -✅ Phase 19 complete. +✅ Phase 19 complete — see `rules/completion-log.md`. -**Scope:** Approached the repo as a brand-new developer with no prior context. Read each tutorial, found every code snippet, and verified signatures, `required` keywords, default values, interface inheritance, and method completeness against actual source. +### Phase 20 — Tutorial Audit as New Developer (Round 7) -**Findings:** 8 tutorials had issues; 42 passed clean. +✅ Phase 20 complete — fixed 7 tutorials (03, 17, 26, 28, 29, 45, 48) plus INormalizer.cs xmldoc. -| Tutorial | Issue | Fix Applied | -|----------|-------|-------------| -| 03 — First Message | `IntegrationEnvelope` missing `required` keyword on 6 properties (`MessageId`, `CorrelationId`, `Timestamp`, `Source`, `MessageType`, `Payload`); `Priority` and `Metadata` missing default values; property order didn't match source | Rewrote record with `required` keywords, defaults, and correct property order | -| 05 — Message Brokers | `IMessageBrokerConsumer` missing `: IAsyncDisposable` inheritance | Added `: IAsyncDisposable` | -| 06 — Messaging Channels | `IInvalidMessageChannel` missing `RouteRawInvalidAsync` method for handling unparseable raw data | Added method + explanatory note | -| 08 — Activities & Pipeline | `PipelineOrchestrator.ProcessAsync` shown as generic `` but actual source uses `IntegrationEnvelope` — class also not `sealed` | Fixed to `JsonElement`, added `sealed`, corrected param name | -| 32 — Multi-Tenancy | `ITenantOnboardingService` missing `GetStatusAsync` method | Added `GetStatusAsync` with nullable return | -| 42 — Configuration | `ConfigurationChangeNotifier` missing `: IDisposable` inheritance | Added `: IDisposable` | -| 48 — Notification Use Cases | `NotificationFeatureFlags.Enabled` constant doesn't exist — actual is `NotificationsEnabled`; `NotificationDecisionService` class presented as real code but doesn't exist in source | Fixed constant name; added pseudocode disclaimer comment | -| 49 — Testing Integrations | Test example used non-existent `NotificationDecisionService` class | Replaced with actual `XmlNotificationMapperTests` from source; updated exercise | +### Phase 21 — Tutorial Code Snippet Accuracy Audit + +✅ Phase 21 complete — fixed 4 tutorials (26, 31, 35, 38) with code snippets mismatched against actual source code. + +--- + +### Phase 22 — Implement Unfulfilled Tutorial Promises + +**Scope:** Audit of all 50 tutorials against source code found 13 features that tutorials promise but are not implemented. These chunks implement the missing features so that every tutorial claim is backed by working code. + +#### Chunk 080 — SFTP Connection Pooling + +| Field | Value | +|-------|-------| +| Status | `not-started` | +| Tutorial | 35 — SFTP Connector (line 78) | +| Claim | "The connector pools connections per host and reuses them across requests." | +| Current State | `SftpConnectorOptions` has no pool config. No pooling code exists. | +| Implementation | Add `MaxConnectionsPerHost` (default 5) and `ConnectionIdleTimeoutMs` (default 30000) to `SftpConnectorOptions`. Create `SftpConnectionPool` class in `src/Connector.Sftp/` that manages a `ConcurrentDictionary>` per host. `ISftpConnector` implementation should acquire/release from pool. Add unit tests. | +| Files | `src/Connector.Sftp/SftpConnectorOptions.cs`, new `src/Connector.Sftp/SftpConnectionPool.cs`, `tests/UnitTests/SftpConnectorTests.cs` | + +#### Chunk 081 — Unified Broker Selection via AddIngestion + +| Field | Value | +|-------|-------| +| Status | `not-started` | +| Tutorial | 05 — Message Brokers (line 124) | +| Claim | `services.AddIngestion(options => { options.BrokerType = BrokerType.NatsJetStream; })` — a unified DI registration method that reads `BrokerType` and registers the correct producer/consumer. | +| Current State | `BrokerType` enum exists and is used in `BrokerTransactionalClient`, but no `AddIngestion()` method exists that wires up producer/consumer based on `BrokerType`. Each broker must be registered separately. | +| Implementation | Add `AddIngestion(Action configure)` to `IngestionServiceExtensions.cs` that switches on `BrokerOptions.BrokerType` to call `AddNatsJetStreamBroker`, `AddKafkaBroker`, or `AddPulsarBroker`. Add unit tests. | +| Files | `src/Ingestion/IngestionServiceExtensions.cs`, `tests/UnitTests/IngestionServiceExtensionsTests.cs` | + +#### Chunk 082 — MessageFilter No-Silent-Drop Enforcement + +| Field | Value | +|-------|-------| +| Status | `not-started` | +| Tutorial | 10 — Message Filter (line 94) | +| Claim | "The platform enforces no silent drops in production deployments." and "If the DLQ publish fails, the source message is Nacked and redelivered." | +| Current State | `MessageFilter.FilterAsync()` silently discards when `DiscardTopic` is null. No Nack-on-DLQ-failure logic exists. | +| Implementation | Add `RequireDiscardTopic` boolean (default false) to `MessageFilterOptions`. When true, throw `InvalidOperationException` if `DiscardTopic` is not set. Wrap the discard publish in try-catch; on failure, throw so the caller can Nack. Add unit tests for both behaviors. | +| Files | `src/Processing.Routing/MessageFilterOptions.cs`, `src/Processing.Routing/MessageFilter.cs`, `tests/UnitTests/MessageFilterTests.cs` | + +#### Chunk 083 — Content Enricher: Database and Cache Sources + +| Field | Value | +|-------|-------| +| Status | `not-started` | +| Tutorial | 18 — Content Enricher (line 7) | +| Claim | "Enrichment sources: HTTP lookups, database queries, cache" | +| Current State | `ContentEnricher` only supports HTTP GET. No database or cache enrichment. | +| Implementation | Extract enrichment source as `IEnrichmentSource` interface with `FetchAsync(string lookupKey, CancellationToken ct)`. Implement `HttpEnrichmentSource` (extract current HTTP logic), `DatabaseEnrichmentSource` (uses `IDbConnection` with parameterized SQL from options), and `CachedEnrichmentSource` (decorator using `IMemoryCache` with configurable TTL). `ContentEnricher` takes `IEnrichmentSource` instead of `IHttpClientFactory`. Add `EnrichmentSourceType` enum to options. Add unit tests for each source and caching behavior. | +| Files | New `src/Processing.Transform/IEnrichmentSource.cs`, `src/Processing.Transform/HttpEnrichmentSource.cs`, `src/Processing.Transform/DatabaseEnrichmentSource.cs`, `src/Processing.Transform/CachedEnrichmentSource.cs`, `src/Processing.Transform/ContentEnricherOptions.cs`, `src/Processing.Transform/ContentEnricher.cs`, `tests/UnitTests/ContentEnricherTests.cs` | + +#### Chunk 084 — Normalizer: Use XmlRootName Option + +| Field | Value | +|-------|-------| +| Status | `not-started` | +| Tutorial | 17 — Normalizer (line 82) | +| Claim | "Root element name used when converting non-XML formats to XML" | +| Current State | `NormalizerOptions.XmlRootName` property exists but is never read by `MessageNormalizer`. Dead code. | +| Implementation | If the normalizer is asked to produce XML output (or if a future XML output mode is added), use `XmlRootName` as the root element. Alternatively, if only JSON output is supported, use `XmlRootName` when parsing XML→JSON to name the wrapper property. Document the actual usage in xmldoc. Add unit test proving the option is respected. | +| Files | `src/Processing.Transform/MessageNormalizer.cs`, `src/Processing.Transform/NormalizerOptions.cs`, `tests/UnitTests/MessageNormalizerTests.cs` | + +#### Chunk 085 — Aggregator Store Idempotency on MessageId + +| Field | Value | +|-------|-------| +| Status | `not-started` | +| Tutorial | 21 — Aggregator (line 112) | +| Claim | "the store must be idempotent on MessageId" — redelivered messages should not be duplicated in the aggregation group. | +| Current State | `InMemoryMessageAggregateStore.AddAsync()` blindly appends every envelope. Duplicate `MessageId` values are not detected. | +| Implementation | In `AddAsync`, check if any existing envelope in the group has the same `MessageId`. If so, skip the add and return the existing snapshot. Add unit tests for duplicate detection. | +| Files | `src/Processing.Aggregator/InMemoryMessageAggregateStore.cs`, `tests/UnitTests/InMemoryMessageAggregateStoreTests.cs` | + +#### Chunk 086 — ReplayId Header Injection in MessageReplayer + +| Field | Value | +|-------|-------| +| Status | `not-started` | +| Tutorial | 26 — Message Replay (lines 9, 31, 34, 94, 106, 114) | +| Claim | "Every replayed message receives a `ReplayId` header (a GUID) linking it back to the replay operation" for audit-trail separation and idempotent consumer deduplication. | +| Current State | `MessageReplayer.ReplayAsync()` copies envelope metadata but never injects a `ReplayId` header. `SkippedCount` is always 0. | +| Implementation | Generate a single `ReplayId` (GUID) per `ReplayAsync` invocation. Add `MessageHeaders.ReplayId` constant to `src/Contracts/MessageHeaders.cs`. Inject `replayedEnvelope.Metadata[MessageHeaders.ReplayId] = replayId.ToString()` for each message. Track skipped messages (e.g. if a message was already replayed based on presence of existing `ReplayId` and dedup option in `ReplayOptions`). Add unit tests. | +| Files | `src/Contracts/MessageHeaders.cs`, `src/Processing.Replay/MessageReplayer.cs`, `src/Processing.Replay/ReplayOptions.cs`, `tests/UnitTests/MessageReplayerTests.cs` | + +#### Chunk 087 — Backpressure Pauses Scale-Down in Competing Consumers + +| Field | Value | +|-------|-------| +| Status | `not-started` | +| Tutorial | 28 — Competing Consumers (line 113) | +| Claim | "When `IsBackpressured` is true, the orchestrator pauses scale-down and can signal upstream producers (via broker flow control or HTTP 429) to slow ingestion." | +| Current State | `CompetingConsumerOrchestrator.EvaluateAndScaleAsync()` never reads `_backpressure.IsBackpressured`. Scale-down proceeds regardless of backpressure state. | +| Implementation | In the scale-down branch of `EvaluateAndScaleAsync`, check `_backpressure.IsBackpressured` and skip scale-down if true (log a warning instead). Add unit test verifying scale-down is skipped during backpressure. | +| Files | `src/Processing.CompetingConsumers/CompetingConsumerOrchestrator.cs`, `tests/UnitTests/CompetingConsumersTests/CompetingConsumerOrchestratorTests.cs` | + +#### Chunk 088 — Rule Engine In-Memory Caching with Periodic Refresh + +| Field | Value | +|-------|-------| +| Status | `not-started` | +| Tutorial | 30 — Rule Engine (line 134) | +| Claim | "Rules are cached in memory and refreshed periodically." | +| Current State | `BusinessRuleEngine.EvaluateAsync()` calls `_ruleStore.GetAllAsync()` on every single message evaluation. No caching. | +| Implementation | Add `CacheEnabled` (default true) and `CacheRefreshIntervalMs` (default 60000) to `RuleEngineOptions`. In `BusinessRuleEngine`, maintain a `IReadOnlyList? _cachedRules` field and a `DateTimeOffset _lastRefresh`. On `EvaluateAsync`, if cache is stale (elapsed > interval) or null, refresh from store. Add unit tests for cache hit, cache miss, and refresh behavior. | +| Files | `src/RuleEngine/RuleEngineOptions.cs`, `src/RuleEngine/BusinessRuleEngine.cs`, `tests/UnitTests/BusinessRuleEngineTests.cs` | + +#### Chunk 089 — InputSanitizer: Script Tag, SQL Injection, HTML Entity, and Control Character Detection + +| Field | Value | +|-------|-------| +| Status | `not-started` | +| Tutorial | 33 — Security (lines 50-54) | +| Claim | Sanitizer detects and removes: script tags (`", RegexOptions.IgnoreCase | RegexOptions.Singleline, matchTimeoutMilliseconds: 1000)] + private static partial Regex ScriptBlockRegex(); + + [GeneratedRegex(@"\bon\w+\s*=", RegexOptions.IgnoreCase, matchTimeoutMilliseconds: 1000)] + private static partial Regex InlineEventHandlerRegex(); + + [GeneratedRegex(@"(?:';\s*DROP\s+TABLE|(?:^|\s)OR\s+1\s*=\s*1|UNION\s+SELECT)", RegexOptions.IgnoreCase, matchTimeoutMilliseconds: 1000)] + private static partial Regex SqlInjectionRegex(); + /// public string Sanitize(string input) { ArgumentNullException.ThrowIfNull(input); - // Replace CRLF with space (preserves readability in logs), remove null bytes. - var result = input + + var result = input; + + // 1. Decode HTML entities to neutralize entity-based bypasses (e.g. < → <). + result = HttpUtility.HtmlDecode(result); + + // 2. Strip blocks. + result = ScriptBlockRegex().Replace(result, string.Empty); + + // 3. Remove inline event handlers (onclick=, onerror=, etc.). + result = InlineEventHandlerRegex().Replace(result, string.Empty); + + // 4. Remove SQL injection patterns. + result = SqlInjectionRegex().Replace(result, string.Empty); + + // 5. Replace CRLF with space, remove null bytes. + result = result .Replace('\r', ' ') .Replace('\n', ' ') .Replace("\0", string.Empty, StringComparison.Ordinal); + + // 6. Remove Unicode direction override characters. + var overrideSet = new HashSet(UnicodeOverrides); + if (result.Any(c => overrideSet.Contains(c))) + result = new string(result.Where(c => !overrideSet.Contains(c)).ToArray()); + return result.Trim(); } @@ -25,6 +67,33 @@ public string Sanitize(string input) public bool IsClean(string input) { ArgumentNullException.ThrowIfNull(input); - return input.IndexOfAny(DangerousChars) < 0; + + // Check for CRLF and null bytes. + if (input.IndexOfAny(DangerousChars) >= 0) + return false; + + // Check for Unicode direction overrides. + if (input.IndexOfAny(UnicodeOverrides) >= 0) + return false; + + // Check for script tags. + if (ScriptBlockRegex().IsMatch(input)) + return false; + + // Check for inline event handlers. + if (InlineEventHandlerRegex().IsMatch(input)) + return false; + + // Check for SQL injection patterns. + if (SqlInjectionRegex().IsMatch(input)) + return false; + + // Check for HTML entities that could bypass filters. + if (input.Contains("&#", StringComparison.Ordinal) || + input.Contains("<", StringComparison.OrdinalIgnoreCase) || + input.Contains(">", StringComparison.OrdinalIgnoreCase)) + return false; + + return true; } } diff --git a/EnterpriseIntegrationPlatform/tests/UnitTests/InputSanitizerTests.cs b/EnterpriseIntegrationPlatform/tests/UnitTests/InputSanitizerTests.cs index 90e846b..81197a5 100644 --- a/EnterpriseIntegrationPlatform/tests/UnitTests/InputSanitizerTests.cs +++ b/EnterpriseIntegrationPlatform/tests/UnitTests/InputSanitizerTests.cs @@ -72,4 +72,107 @@ public void IsClean_InputWithNullByte_ReturnsFalse() { Assert.That(_sanitizer.IsClean("bad\0input"), Is.False); } + + // ───── XSS Detection ───── + + [Test] + public void Sanitize_ScriptTag_Removed() + { + var result = _sanitizer.Sanitize("HelloWorld"); + Assert.That(result, Does.Not.Contain("World"), Is.False); + } + + [Test] + public void Sanitize_InlineEventHandler_Removed() + { + var result = _sanitizer.Sanitize("img onclick= onerror= src=x"); + Assert.That(result, Does.Not.Contain("onclick=")); + Assert.That(result, Does.Not.Contain("onerror=")); + } + + [Test] + public void IsClean_InlineEventHandler_ReturnsFalse() + { + Assert.That(_sanitizer.IsClean("img onclick=alert(1)"), Is.False); + } + + // ───── SQL Injection Detection ───── + + [Test] + public void Sanitize_SqlDropTable_Removed() + { + var result = _sanitizer.Sanitize("'; DROP TABLE users"); + Assert.That(result, Does.Not.Contain("DROP TABLE")); + } + + [Test] + public void Sanitize_SqlOrOneEqualsOne_Removed() + { + var result = _sanitizer.Sanitize("admin' OR 1=1 --"); + Assert.That(result, Does.Not.Contain("OR 1=1")); + } + + [Test] + public void Sanitize_SqlUnionSelect_Removed() + { + var result = _sanitizer.Sanitize("1 UNION SELECT * FROM users"); + Assert.That(result, Does.Not.Contain("UNION SELECT")); + } + + [Test] + public void IsClean_SqlInjection_ReturnsFalse() + { + Assert.That(_sanitizer.IsClean("'; DROP TABLE users"), Is.False); + Assert.That(_sanitizer.IsClean(" OR 1=1"), Is.False); + Assert.That(_sanitizer.IsClean("1 UNION SELECT *"), Is.False); + } + + // ───── HTML Entity Detection ───── + + [Test] + public void Sanitize_HtmlEntities_DecodedAndNeutralized() + { + var result = _sanitizer.Sanitize("<script>alert(1)</script>"); + Assert.That(result, Does.Not.Contain("