Implements detect-redact-sanitize pipeline with regex, domain rules, and ONNX NER before the LLM boundary, plus NUnit tests and Xenovex push documentation. Co-authored-by: Cursor <cursoragent@cursor.com>
128 lines
4.7 KiB
C#
128 lines
4.7 KiB
C#
using FluentAssertions;
|
|
using PiiRedaction.Core.Abstractions;
|
|
using PiiRedaction.Core.Detection;
|
|
using PiiRedaction.Core.Models;
|
|
|
|
namespace PiiRedaction.Core.Tests.Detection;
|
|
|
|
[TestFixture]
|
|
public sealed class CompositePiiDetectorTests
|
|
{
|
|
[Test]
|
|
public void Detect_AdjacentSpans_KeepsBothEntities()
|
|
{
|
|
var composite = new CompositePiiDetector([new FixedDetector(
|
|
new PiiEntity(PiiEntityType.Email, "a@b.co", 0, 6, PiiDetectionSource.Regex),
|
|
new PiiEntity(PiiEntityType.Phone, "9876543210", 6, 10, PiiDetectionSource.Regex))]);
|
|
|
|
var entities = composite.Detect("abcdef9876543210padding");
|
|
|
|
entities.Should().HaveCount(2);
|
|
}
|
|
|
|
[Test]
|
|
public void Detect_NestedSpan_KeepsLongerSpan()
|
|
{
|
|
var composite = new CompositePiiDetector([new FixedDetector(
|
|
new PiiEntity(PiiEntityType.Aadhaar, "123456789012", 0, 12, PiiDetectionSource.Regex),
|
|
new PiiEntity(PiiEntityType.Phone, "4567890123", 2, 10, PiiDetectionSource.Regex))]);
|
|
|
|
var entities = composite.Detect("123456789012");
|
|
|
|
entities.Should().ContainSingle(entity => entity.Type == PiiEntityType.Aadhaar);
|
|
}
|
|
|
|
[Test]
|
|
public void Detect_OverlappingSameStart_LongerSpanWins()
|
|
{
|
|
var composite = new CompositePiiDetector([new FixedDetector(
|
|
new PiiEntity(PiiEntityType.LoanNumber, "LN-456789", 0, 9, PiiDetectionSource.Domain),
|
|
new PiiEntity(PiiEntityType.Phone, "456789", 3, 6, PiiDetectionSource.Regex))]);
|
|
|
|
var entities = composite.Detect("LN-456789");
|
|
|
|
entities.Should().ContainSingle(entity => entity.Type == PiiEntityType.LoanNumber);
|
|
}
|
|
|
|
[Test]
|
|
public void Detect_OverlappingDifferentPriority_DomainBeatsRegex()
|
|
{
|
|
var composite = new CompositePiiDetector([new FixedDetector(
|
|
new PiiEntity(PiiEntityType.LoanNumber, "LN-456789", 0, 9, PiiDetectionSource.Domain),
|
|
new PiiEntity(PiiEntityType.Phone, "456789", 0, 6, PiiDetectionSource.Regex))]);
|
|
|
|
var entities = composite.Detect("LN-456789");
|
|
|
|
entities.Should().ContainSingle(entity =>
|
|
entity.Type == PiiEntityType.LoanNumber &&
|
|
entity.Source == PiiDetectionSource.Domain);
|
|
}
|
|
|
|
[Test]
|
|
public void Detect_OverlappingPriority_RegexBeatsNer()
|
|
{
|
|
var composite = new CompositePiiDetector([new FixedDetector(
|
|
new PiiEntity(PiiEntityType.Email, "a@b.co", 0, 6, PiiDetectionSource.Regex),
|
|
new PiiEntity(PiiEntityType.Person, "a@b", 0, 3, PiiDetectionSource.Ner))]);
|
|
|
|
var entities = composite.Detect("a@b.co");
|
|
|
|
entities.Should().ContainSingle(entity => entity.Source == PiiDetectionSource.Regex);
|
|
}
|
|
|
|
[Test]
|
|
public void Detect_OverlappingPriority_DomainBeatsNer()
|
|
{
|
|
var composite = new CompositePiiDetector([new FixedDetector(
|
|
new PiiEntity(PiiEntityType.LoanNumber, "LN-456789", 0, 9, PiiDetectionSource.Domain),
|
|
new PiiEntity(PiiEntityType.Person, "LN-456", 0, 6, PiiDetectionSource.Ner))]);
|
|
|
|
var entities = composite.Detect("LN-456789");
|
|
|
|
entities.Should().ContainSingle(entity => entity.Source == PiiDetectionSource.Domain);
|
|
}
|
|
|
|
[Test]
|
|
public void Detect_DuplicateOverlappingSpan_KeepsFirstAccepted()
|
|
{
|
|
var composite = new CompositePiiDetector([new FixedDetector(
|
|
new PiiEntity(PiiEntityType.Phone, "9876543210", 0, 10, PiiDetectionSource.Regex),
|
|
new PiiEntity(PiiEntityType.Phone, "9876543210", 0, 10, PiiDetectionSource.Regex))]);
|
|
|
|
var entities = composite.Detect("9876543210");
|
|
|
|
entities.Should().HaveCount(1);
|
|
}
|
|
|
|
[Test]
|
|
public void Detect_AadhaarWithEmbeddedPhone_PrefersAadhaarSpan()
|
|
{
|
|
var detector = new CompositePiiDetector(
|
|
[
|
|
new DomainRulePiiDetector(),
|
|
new RegexPiiDetector()
|
|
]);
|
|
|
|
var entities = detector.Detect("Aadhaar 987654321012 phone 9876543210.");
|
|
|
|
entities.Should().Contain(entity => entity.Type == PiiEntityType.Aadhaar && entity.Value == "987654321012");
|
|
entities.Should().Contain(entity => entity.Type == PiiEntityType.Phone && entity.Value == "9876543210");
|
|
entities.Count(entity => entity.Type == PiiEntityType.Phone).Should().Be(1);
|
|
}
|
|
|
|
[TestCase(null)]
|
|
[TestCase("")]
|
|
[TestCase(" ")]
|
|
public void Detect_InvalidInput_ThrowsArgumentException(string? text)
|
|
{
|
|
var composite = new CompositePiiDetector([new RegexPiiDetector()]);
|
|
var action = () => composite.Detect(text!);
|
|
action.Should().Throw<ArgumentException>();
|
|
}
|
|
|
|
private sealed class FixedDetector(params PiiEntity[] entities) : IPiiDetector
|
|
{
|
|
public IReadOnlyList<PiiEntity> Detect(string text) => entities;
|
|
}
|
|
}
|