427 lines
13 KiB
C#
427 lines
13 KiB
C#
using System.Collections.Immutable;
|
|
using FluentAssertions;
|
|
using Xunit;
|
|
|
|
namespace StellaOps.BinaryIndex.GroundTruth.Abstractions.Tests;
|
|
|
|
/// <summary>
|
|
/// Unit tests for AOC (Aggregation-Only Contract) write guard invariants.
|
|
/// </summary>
|
|
public class SymbolObservationWriteGuardTests
|
|
{
|
|
private readonly SymbolObservationWriteGuard _guard = new();
|
|
|
|
#region ValidateWrite Tests
|
|
|
|
[Fact]
|
|
public void ValidateWrite_NewObservation_ReturnsProceed()
|
|
{
|
|
// Arrange
|
|
var observation = CreateValidObservation();
|
|
|
|
// Act
|
|
var result = _guard.ValidateWrite(observation, existingContentHash: null);
|
|
|
|
// Assert
|
|
result.Should().Be(WriteDisposition.Proceed);
|
|
}
|
|
|
|
[Fact]
|
|
public void ValidateWrite_IdenticalContentHash_ReturnsSkipIdentical()
|
|
{
|
|
// Arrange
|
|
var observation = CreateValidObservation();
|
|
var existingHash = observation.ContentHash;
|
|
|
|
// Act
|
|
var result = _guard.ValidateWrite(observation, existingHash);
|
|
|
|
// Assert
|
|
result.Should().Be(WriteDisposition.SkipIdentical);
|
|
}
|
|
|
|
[Fact]
|
|
public void ValidateWrite_DifferentContentHash_ReturnsRejectMutation()
|
|
{
|
|
// Arrange
|
|
var observation = CreateValidObservation();
|
|
var existingHash = "sha256:differenthash";
|
|
|
|
// Act
|
|
var result = _guard.ValidateWrite(observation, existingHash);
|
|
|
|
// Assert
|
|
result.Should().Be(WriteDisposition.RejectMutation);
|
|
}
|
|
|
|
[Fact]
|
|
public void ValidateWrite_CaseInsensitiveHashComparison_ReturnsSkipIdentical()
|
|
{
|
|
// Arrange
|
|
var observation = CreateValidObservation();
|
|
var existingHash = observation.ContentHash.ToUpperInvariant();
|
|
|
|
// Act
|
|
var result = _guard.ValidateWrite(observation, existingHash);
|
|
|
|
// Assert
|
|
result.Should().Be(WriteDisposition.SkipIdentical);
|
|
}
|
|
|
|
#endregion
|
|
|
|
#region EnsureValid - Required Fields Tests
|
|
|
|
[Fact]
|
|
public void EnsureValid_ValidObservation_DoesNotThrow()
|
|
{
|
|
// Arrange
|
|
var observation = CreateValidObservation();
|
|
|
|
// Act & Assert
|
|
var act = () => _guard.EnsureValid(observation);
|
|
act.Should().NotThrow();
|
|
}
|
|
|
|
[Fact]
|
|
public void EnsureValid_MissingObservationId_ThrowsWithCorrectCode()
|
|
{
|
|
// Arrange
|
|
var observation = CreateValidObservation() with { ObservationId = "" };
|
|
|
|
// Act & Assert
|
|
var act = () => _guard.EnsureValid(observation);
|
|
act.Should().Throw<GroundTruthAocGuardException>()
|
|
.Where(ex => ex.Violations.Any(v => v.Code == AocViolationCodes.MissingRequiredField))
|
|
.Where(ex => ex.Violations.Any(v => v.Path == "observationId"));
|
|
}
|
|
|
|
[Fact]
|
|
public void EnsureValid_MissingSourceId_ThrowsWithCorrectCode()
|
|
{
|
|
// Arrange
|
|
var observation = CreateValidObservation() with { SourceId = "" };
|
|
|
|
// Act & Assert
|
|
var act = () => _guard.EnsureValid(observation);
|
|
act.Should().Throw<GroundTruthAocGuardException>()
|
|
.Where(ex => ex.Violations.Any(v => v.Code == AocViolationCodes.MissingRequiredField))
|
|
.Where(ex => ex.Violations.Any(v => v.Path == "sourceId"));
|
|
}
|
|
|
|
[Fact]
|
|
public void EnsureValid_MissingDebugId_ThrowsWithCorrectCode()
|
|
{
|
|
// Arrange
|
|
var observation = CreateValidObservation() with { DebugId = "" };
|
|
|
|
// Act & Assert
|
|
var act = () => _guard.EnsureValid(observation);
|
|
act.Should().Throw<GroundTruthAocGuardException>()
|
|
.Where(ex => ex.Violations.Any(v => v.Code == AocViolationCodes.MissingRequiredField))
|
|
.Where(ex => ex.Violations.Any(v => v.Path == "debugId"));
|
|
}
|
|
|
|
[Fact]
|
|
public void EnsureValid_MissingBinaryName_ThrowsWithCorrectCode()
|
|
{
|
|
// Arrange
|
|
var observation = CreateValidObservation() with { BinaryName = "" };
|
|
|
|
// Act & Assert
|
|
var act = () => _guard.EnsureValid(observation);
|
|
act.Should().Throw<GroundTruthAocGuardException>()
|
|
.Where(ex => ex.Violations.Any(v => v.Code == AocViolationCodes.MissingRequiredField))
|
|
.Where(ex => ex.Violations.Any(v => v.Path == "binaryName"));
|
|
}
|
|
|
|
[Fact]
|
|
public void EnsureValid_MissingArchitecture_ThrowsWithCorrectCode()
|
|
{
|
|
// Arrange
|
|
var observation = CreateValidObservation() with { Architecture = "" };
|
|
|
|
// Act & Assert
|
|
var act = () => _guard.EnsureValid(observation);
|
|
act.Should().Throw<GroundTruthAocGuardException>()
|
|
.Where(ex => ex.Violations.Any(v => v.Code == AocViolationCodes.MissingRequiredField))
|
|
.Where(ex => ex.Violations.Any(v => v.Path == "architecture"));
|
|
}
|
|
|
|
#endregion
|
|
|
|
#region EnsureValid - Provenance Tests (GTAOC_001)
|
|
|
|
[Fact]
|
|
public void EnsureValid_MissingProvenance_ThrowsWithCorrectCode()
|
|
{
|
|
// Arrange
|
|
var observation = CreateValidObservation() with { Provenance = null! };
|
|
|
|
// Act & Assert
|
|
var act = () => _guard.EnsureValid(observation);
|
|
act.Should().Throw<GroundTruthAocGuardException>()
|
|
.Where(ex => ex.Violations.Any(v => v.Code == AocViolationCodes.MissingProvenance));
|
|
}
|
|
|
|
[Fact]
|
|
public void EnsureValid_MissingProvenanceSourceId_ThrowsWithCorrectCode()
|
|
{
|
|
// Arrange
|
|
var observation = CreateValidObservation() with
|
|
{
|
|
Provenance = CreateValidProvenance() with { SourceId = "" }
|
|
};
|
|
|
|
// Act & Assert
|
|
var act = () => _guard.EnsureValid(observation);
|
|
act.Should().Throw<GroundTruthAocGuardException>()
|
|
.Where(ex => ex.Violations.Any(v => v.Code == AocViolationCodes.MissingProvenance))
|
|
.Where(ex => ex.Violations.Any(v => v.Path == "provenance.sourceId"));
|
|
}
|
|
|
|
[Fact]
|
|
public void EnsureValid_MissingProvenanceDocumentUri_ThrowsWithCorrectCode()
|
|
{
|
|
// Arrange
|
|
var observation = CreateValidObservation() with
|
|
{
|
|
Provenance = CreateValidProvenance() with { DocumentUri = "" }
|
|
};
|
|
|
|
// Act & Assert
|
|
var act = () => _guard.EnsureValid(observation);
|
|
act.Should().Throw<GroundTruthAocGuardException>()
|
|
.Where(ex => ex.Violations.Any(v => v.Code == AocViolationCodes.MissingProvenance))
|
|
.Where(ex => ex.Violations.Any(v => v.Path == "provenance.documentUri"));
|
|
}
|
|
|
|
[Fact]
|
|
public void EnsureValid_MissingProvenanceDocumentHash_ThrowsWithCorrectCode()
|
|
{
|
|
// Arrange
|
|
var observation = CreateValidObservation() with
|
|
{
|
|
Provenance = CreateValidProvenance() with { DocumentHash = "" }
|
|
};
|
|
|
|
// Act & Assert
|
|
var act = () => _guard.EnsureValid(observation);
|
|
act.Should().Throw<GroundTruthAocGuardException>()
|
|
.Where(ex => ex.Violations.Any(v => v.Code == AocViolationCodes.MissingProvenance))
|
|
.Where(ex => ex.Violations.Any(v => v.Path == "provenance.documentHash"));
|
|
}
|
|
|
|
[Fact]
|
|
public void EnsureValid_DefaultProvenanceFetchedAt_ThrowsWithCorrectCode()
|
|
{
|
|
// Arrange
|
|
var observation = CreateValidObservation() with
|
|
{
|
|
Provenance = CreateValidProvenance() with { FetchedAt = default }
|
|
};
|
|
|
|
// Act & Assert
|
|
var act = () => _guard.EnsureValid(observation);
|
|
act.Should().Throw<GroundTruthAocGuardException>()
|
|
.Where(ex => ex.Violations.Any(v => v.Code == AocViolationCodes.MissingProvenance))
|
|
.Where(ex => ex.Violations.Any(v => v.Path == "provenance.fetchedAt"));
|
|
}
|
|
|
|
#endregion
|
|
|
|
#region EnsureValid - Content Hash Tests (GTAOC_004)
|
|
|
|
[Fact]
|
|
public void EnsureValid_InvalidContentHash_ThrowsWithCorrectCode()
|
|
{
|
|
// Arrange
|
|
var observation = CreateValidObservation() with
|
|
{
|
|
ContentHash = "sha256:invalidhash"
|
|
};
|
|
|
|
// Act & Assert
|
|
var act = () => _guard.EnsureValid(observation);
|
|
act.Should().Throw<GroundTruthAocGuardException>()
|
|
.Where(ex => ex.Violations.Any(v => v.Code == AocViolationCodes.InvalidContentHash));
|
|
}
|
|
|
|
[Fact]
|
|
public void ComputeContentHash_DeterministicForSameInput()
|
|
{
|
|
// Arrange
|
|
var observation = CreateValidObservation();
|
|
|
|
// Act
|
|
var hash1 = SymbolObservationWriteGuard.ComputeContentHash(observation);
|
|
var hash2 = SymbolObservationWriteGuard.ComputeContentHash(observation);
|
|
|
|
// Assert
|
|
hash1.Should().Be(hash2);
|
|
}
|
|
|
|
[Fact]
|
|
public void ComputeContentHash_DifferentForDifferentInput()
|
|
{
|
|
// Arrange
|
|
var observation1 = CreateValidObservation();
|
|
var observation2 = CreateValidObservation() with { DebugId = "different-debug-id" };
|
|
|
|
// Act
|
|
var hash1 = SymbolObservationWriteGuard.ComputeContentHash(observation1);
|
|
var hash2 = SymbolObservationWriteGuard.ComputeContentHash(observation2);
|
|
|
|
// Assert
|
|
hash1.Should().NotBe(hash2);
|
|
}
|
|
|
|
[Fact]
|
|
public void ComputeContentHash_StartsWithSha256Prefix()
|
|
{
|
|
// Arrange
|
|
var observation = CreateValidObservation();
|
|
|
|
// Act
|
|
var hash = SymbolObservationWriteGuard.ComputeContentHash(observation);
|
|
|
|
// Assert
|
|
hash.Should().StartWith("sha256:");
|
|
}
|
|
|
|
#endregion
|
|
|
|
#region EnsureValid - Supersession Chain Tests (GTAOC_006)
|
|
|
|
[Fact]
|
|
public void EnsureValid_SupersedesItself_ThrowsWithCorrectCode()
|
|
{
|
|
// Arrange
|
|
var observationId = "groundtruth:test-source:build123:1";
|
|
var observation = CreateValidObservation() with
|
|
{
|
|
ObservationId = observationId,
|
|
SupersedesId = observationId
|
|
};
|
|
|
|
// Act & Assert
|
|
var act = () => _guard.EnsureValid(observation);
|
|
act.Should().Throw<GroundTruthAocGuardException>()
|
|
.Where(ex => ex.Violations.Any(v => v.Code == AocViolationCodes.InvalidSupersession));
|
|
}
|
|
|
|
[Fact]
|
|
public void EnsureValid_ValidSupersession_DoesNotThrow()
|
|
{
|
|
// Arrange
|
|
var observation = CreateValidObservation() with
|
|
{
|
|
ObservationId = "groundtruth:test-source:build123:2",
|
|
SupersedesId = "groundtruth:test-source:build123:1"
|
|
};
|
|
|
|
// Act & Assert
|
|
var act = () => _guard.EnsureValid(observation);
|
|
act.Should().NotThrow();
|
|
}
|
|
|
|
[Fact]
|
|
public void EnsureValid_NullSupersedes_DoesNotThrow()
|
|
{
|
|
// Arrange
|
|
var observation = CreateValidObservation() with { SupersedesId = null };
|
|
|
|
// Act & Assert
|
|
var act = () => _guard.EnsureValid(observation);
|
|
act.Should().NotThrow();
|
|
}
|
|
|
|
#endregion
|
|
|
|
#region Multiple Violations Tests
|
|
|
|
[Fact]
|
|
public void EnsureValid_MultipleViolations_ReportsAll()
|
|
{
|
|
// Arrange
|
|
var observation = CreateValidObservation() with
|
|
{
|
|
ObservationId = "",
|
|
SourceId = "",
|
|
DebugId = ""
|
|
};
|
|
|
|
// Act & Assert
|
|
var act = () => _guard.EnsureValid(observation);
|
|
act.Should().Throw<GroundTruthAocGuardException>()
|
|
.Where(ex => ex.Violations.Count >= 3);
|
|
}
|
|
|
|
#endregion
|
|
|
|
#region AocViolation Record Tests
|
|
|
|
[Fact]
|
|
public void AocViolation_RecordEquality()
|
|
{
|
|
// Arrange
|
|
var v1 = new AocViolation(AocViolationCodes.MissingProvenance, "test", "path", AocViolationSeverity.Error);
|
|
var v2 = new AocViolation(AocViolationCodes.MissingProvenance, "test", "path", AocViolationSeverity.Error);
|
|
var v3 = new AocViolation(AocViolationCodes.MissingRequiredField, "test", "path", AocViolationSeverity.Error);
|
|
|
|
// Assert
|
|
v1.Should().Be(v2);
|
|
v1.Should().NotBe(v3);
|
|
}
|
|
|
|
#endregion
|
|
|
|
#region Helper Methods
|
|
|
|
private static SymbolObservation CreateValidObservation()
|
|
{
|
|
var provenance = CreateValidProvenance();
|
|
var symbols = ImmutableArray.Create(new ObservedSymbol
|
|
{
|
|
Name = "main",
|
|
Address = 0x1000,
|
|
Size = 100,
|
|
Type = SymbolType.Function,
|
|
Binding = SymbolBinding.Global
|
|
});
|
|
|
|
var baseObservation = new SymbolObservation
|
|
{
|
|
ObservationId = "groundtruth:test-source:abcd1234:1",
|
|
SourceId = "test-source",
|
|
DebugId = "abcd1234",
|
|
BinaryName = "test.so",
|
|
Architecture = "x86_64",
|
|
Symbols = symbols,
|
|
SymbolCount = 1,
|
|
Provenance = provenance,
|
|
ContentHash = "", // Will be computed
|
|
CreatedAt = DateTimeOffset.UtcNow
|
|
};
|
|
|
|
// Compute and set the correct content hash
|
|
var hash = SymbolObservationWriteGuard.ComputeContentHash(baseObservation);
|
|
return baseObservation with { ContentHash = hash };
|
|
}
|
|
|
|
private static ObservationProvenance CreateValidProvenance()
|
|
{
|
|
return new ObservationProvenance
|
|
{
|
|
SourceId = "test-source",
|
|
DocumentUri = "https://example.com/test.elf",
|
|
FetchedAt = DateTimeOffset.UtcNow.AddMinutes(-5),
|
|
RecordedAt = DateTimeOffset.UtcNow,
|
|
DocumentHash = "sha256:abc123",
|
|
SignatureState = SignatureState.None
|
|
};
|
|
}
|
|
|
|
#endregion
|
|
}
|