Restructure solution layout by module
This commit is contained in:
@@ -0,0 +1,47 @@
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Nodes;
|
||||
using Xunit.Sdk;
|
||||
|
||||
namespace StellaOps.Notify.Models.Tests;
|
||||
|
||||
public sealed class DocSampleTests
|
||||
{
|
||||
[Theory]
|
||||
[InlineData("notify-rule@1.sample.json")]
|
||||
[InlineData("notify-channel@1.sample.json")]
|
||||
[InlineData("notify-template@1.sample.json")]
|
||||
[InlineData("notify-event@1.sample.json")]
|
||||
public void CanonicalSamplesStayInSync(string fileName)
|
||||
{
|
||||
var json = LoadSample(fileName);
|
||||
var node = JsonNode.Parse(json) ?? throw new InvalidOperationException("Sample JSON null.");
|
||||
|
||||
string canonical = fileName switch
|
||||
{
|
||||
"notify-rule@1.sample.json" => NotifyCanonicalJsonSerializer.Serialize(NotifySchemaMigration.UpgradeRule(node)),
|
||||
"notify-channel@1.sample.json" => NotifyCanonicalJsonSerializer.Serialize(NotifySchemaMigration.UpgradeChannel(node)),
|
||||
"notify-template@1.sample.json" => NotifyCanonicalJsonSerializer.Serialize(NotifySchemaMigration.UpgradeTemplate(node)),
|
||||
"notify-event@1.sample.json" => NotifyCanonicalJsonSerializer.Serialize(NotifyCanonicalJsonSerializer.Deserialize<NotifyEvent>(json)),
|
||||
_ => throw new ArgumentOutOfRangeException(nameof(fileName), fileName, "Unsupported sample.")
|
||||
};
|
||||
|
||||
var canonicalNode = JsonNode.Parse(canonical) ?? throw new InvalidOperationException("Canonical JSON null.");
|
||||
if (!JsonNode.DeepEquals(node, canonicalNode))
|
||||
{
|
||||
var expected = canonicalNode.ToJsonString(new JsonSerializerOptions { WriteIndented = true });
|
||||
var actual = node.ToJsonString(new JsonSerializerOptions { WriteIndented = true });
|
||||
throw new XunitException($"Sample '{fileName}' must remain canonical.\nExpected:\n{expected}\nActual:\n{actual}");
|
||||
}
|
||||
}
|
||||
|
||||
private static string LoadSample(string fileName)
|
||||
{
|
||||
var path = Path.Combine(AppContext.BaseDirectory, fileName);
|
||||
if (!File.Exists(path))
|
||||
{
|
||||
throw new FileNotFoundException($"Unable to load sample '{fileName}'.", path);
|
||||
}
|
||||
|
||||
return File.ReadAllText(path);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Text.Json.Nodes;
|
||||
|
||||
namespace StellaOps.Notify.Models.Tests;
|
||||
|
||||
public sealed class NotifyCanonicalJsonSerializerTests
|
||||
{
|
||||
[Fact]
|
||||
public void SerializeRuleIsDeterministic()
|
||||
{
|
||||
var ruleA = NotifyRule.Create(
|
||||
ruleId: "rule-1",
|
||||
tenantId: "tenant-a",
|
||||
name: "critical",
|
||||
match: NotifyRuleMatch.Create(eventKinds: new[] { NotifyEventKinds.ScannerReportReady }),
|
||||
actions: new[]
|
||||
{
|
||||
NotifyRuleAction.Create(actionId: "b", channel: "slack:sec"),
|
||||
NotifyRuleAction.Create(actionId: "a", channel: "email:soc")
|
||||
},
|
||||
metadata: new Dictionary<string, string>
|
||||
{
|
||||
["beta"] = "2",
|
||||
["alpha"] = "1"
|
||||
},
|
||||
createdAt: DateTimeOffset.Parse("2025-10-18T00:00:00Z"),
|
||||
updatedAt: DateTimeOffset.Parse("2025-10-18T00:00:00Z"));
|
||||
|
||||
var ruleB = NotifyRule.Create(
|
||||
ruleId: "rule-1",
|
||||
tenantId: "tenant-a",
|
||||
name: "critical",
|
||||
match: NotifyRuleMatch.Create(eventKinds: new[] { NotifyEventKinds.ScannerReportReady }),
|
||||
actions: new[]
|
||||
{
|
||||
NotifyRuleAction.Create(actionId: "a", channel: "email:soc"),
|
||||
NotifyRuleAction.Create(actionId: "b", channel: "slack:sec")
|
||||
},
|
||||
metadata: new Dictionary<string, string>
|
||||
{
|
||||
["alpha"] = "1",
|
||||
["beta"] = "2"
|
||||
},
|
||||
createdAt: DateTimeOffset.Parse("2025-10-18T00:00:00Z"),
|
||||
updatedAt: DateTimeOffset.Parse("2025-10-18T00:00:00Z"));
|
||||
|
||||
var jsonA = NotifyCanonicalJsonSerializer.Serialize(ruleA);
|
||||
var jsonB = NotifyCanonicalJsonSerializer.Serialize(ruleB);
|
||||
|
||||
Assert.Equal(jsonA, jsonB);
|
||||
Assert.Contains("\"schemaVersion\":\"notify.rule@1\"", jsonA, StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SerializeEventOrdersPayloadKeys()
|
||||
{
|
||||
var payload = JsonNode.Parse("{\"b\":2,\"a\":1}");
|
||||
var @event = NotifyEvent.Create(
|
||||
eventId: Guid.NewGuid(),
|
||||
kind: NotifyEventKinds.ScannerReportReady,
|
||||
tenant: "tenant-a",
|
||||
ts: DateTimeOffset.Parse("2025-10-18T05:41:22Z"),
|
||||
payload: payload,
|
||||
scope: NotifyEventScope.Create(repo: "ghcr.io/acme/api", digest: "sha256:123"));
|
||||
|
||||
var json = NotifyCanonicalJsonSerializer.Serialize(@event);
|
||||
|
||||
var payloadIndex = json.IndexOf("\"payload\":{", StringComparison.Ordinal);
|
||||
Assert.NotEqual(-1, payloadIndex);
|
||||
|
||||
var aIndex = json.IndexOf("\"a\":1", payloadIndex, StringComparison.Ordinal);
|
||||
var bIndex = json.IndexOf("\"b\":2", payloadIndex, StringComparison.Ordinal);
|
||||
|
||||
Assert.True(aIndex is >= 0 && bIndex is >= 0 && aIndex < bIndex, "Payload keys should be ordered alphabetically.");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
using System;
|
||||
using System.Linq;
|
||||
|
||||
namespace StellaOps.Notify.Models.Tests;
|
||||
|
||||
public sealed class NotifyDeliveryTests
|
||||
{
|
||||
[Fact]
|
||||
public void AttemptsAreSortedChronologically()
|
||||
{
|
||||
var attempts = new[]
|
||||
{
|
||||
new NotifyDeliveryAttempt(DateTimeOffset.Parse("2025-10-19T12:25:00Z"), NotifyDeliveryAttemptStatus.Succeeded),
|
||||
new NotifyDeliveryAttempt(DateTimeOffset.Parse("2025-10-19T12:15:00Z"), NotifyDeliveryAttemptStatus.Sending),
|
||||
};
|
||||
|
||||
var delivery = NotifyDelivery.Create(
|
||||
deliveryId: "delivery-1",
|
||||
tenantId: "tenant-a",
|
||||
ruleId: "rule-1",
|
||||
actionId: "action-1",
|
||||
eventId: Guid.NewGuid(),
|
||||
kind: NotifyEventKinds.ScannerReportReady,
|
||||
status: NotifyDeliveryStatus.Sent,
|
||||
attempts: attempts);
|
||||
|
||||
Assert.Collection(
|
||||
delivery.Attempts,
|
||||
attempt => Assert.Equal(NotifyDeliveryAttemptStatus.Sending, attempt.Status),
|
||||
attempt => Assert.Equal(NotifyDeliveryAttemptStatus.Succeeded, attempt.Status));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RenderedNormalizesAttachments()
|
||||
{
|
||||
var rendered = NotifyDeliveryRendered.Create(
|
||||
channelType: NotifyChannelType.Slack,
|
||||
format: NotifyDeliveryFormat.Slack,
|
||||
target: "#sec",
|
||||
title: "Alert",
|
||||
body: "Body",
|
||||
attachments: new[] { "B", "a", "a" });
|
||||
|
||||
Assert.Equal(new[] { "B", "a" }.OrderBy(x => x, StringComparer.Ordinal), rendered.Attachments);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
|
||||
namespace StellaOps.Notify.Models.Tests;
|
||||
|
||||
public sealed class NotifyRuleTests
|
||||
{
|
||||
[Fact]
|
||||
public void ConstructorThrowsWhenActionsMissing()
|
||||
{
|
||||
var match = NotifyRuleMatch.Create(eventKinds: new[] { NotifyEventKinds.ScannerReportReady });
|
||||
|
||||
var exception = Assert.Throws<ArgumentException>(() =>
|
||||
NotifyRule.Create(
|
||||
ruleId: "rule-1",
|
||||
tenantId: "tenant-a",
|
||||
name: "critical",
|
||||
match: match,
|
||||
actions: Array.Empty<NotifyRuleAction>()));
|
||||
|
||||
Assert.Contains("At least one action is required", exception.Message, StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ConstructorNormalizesCollections()
|
||||
{
|
||||
var rule = NotifyRule.Create(
|
||||
ruleId: "rule-1",
|
||||
tenantId: "tenant-a",
|
||||
name: "critical",
|
||||
match: NotifyRuleMatch.Create(
|
||||
eventKinds: new[] { "Zastava.Admission", NotifyEventKinds.ScannerReportReady }),
|
||||
actions: new[]
|
||||
{
|
||||
NotifyRuleAction.Create(actionId: "b", channel: "slack:sec-alerts", throttle: TimeSpan.FromMinutes(5)),
|
||||
NotifyRuleAction.Create(actionId: "a", channel: "email:soc", metadata: new Dictionary<string, string>
|
||||
{
|
||||
[" locale "] = " EN-us "
|
||||
})
|
||||
},
|
||||
labels: new Dictionary<string, string>
|
||||
{
|
||||
[" team "] = " SecOps "
|
||||
},
|
||||
metadata: new Dictionary<string, string>
|
||||
{
|
||||
["source"] = "tests"
|
||||
});
|
||||
|
||||
Assert.Equal(NotifySchemaVersions.Rule, rule.SchemaVersion);
|
||||
Assert.Equal(new[] { "scanner.report.ready", "zastava.admission" }, rule.Match.EventKinds);
|
||||
Assert.Equal(new[] { "a", "b" }, rule.Actions.Select(action => action.ActionId));
|
||||
Assert.Equal(TimeSpan.FromMinutes(5), rule.Actions.Last().Throttle);
|
||||
Assert.Equal("secops", rule.Labels.Single().Value.ToLowerInvariant());
|
||||
Assert.Equal("en-us", rule.Actions.First().Metadata["locale"].ToLowerInvariant());
|
||||
|
||||
var json = NotifyCanonicalJsonSerializer.Serialize(rule);
|
||||
Assert.Contains("\"schemaVersion\":\"notify.rule@1\"", json, StringComparison.Ordinal);
|
||||
Assert.Contains("\"actions\":[{\"actionId\":\"a\"", json, StringComparison.Ordinal);
|
||||
Assert.Contains("\"throttle\":\"PT5M\"", json, StringComparison.Ordinal);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
using System;
|
||||
using System.Text.Json.Nodes;
|
||||
|
||||
namespace StellaOps.Notify.Models.Tests;
|
||||
|
||||
public sealed class NotifySchemaMigrationTests
|
||||
{
|
||||
[Fact]
|
||||
public void UpgradeRuleAddsSchemaVersionWhenMissing()
|
||||
{
|
||||
var json = JsonNode.Parse(
|
||||
"""
|
||||
{
|
||||
"ruleId": "rule-legacy",
|
||||
"tenantId": "tenant-1",
|
||||
"name": "legacy",
|
||||
"enabled": true,
|
||||
"match": { "eventKinds": ["scanner.report.ready"] },
|
||||
"actions": [ { "actionId": "send", "channel": "email:legacy", "enabled": true } ],
|
||||
"createdAt": "2025-10-18T00:00:00Z",
|
||||
"updatedAt": "2025-10-18T00:00:00Z"
|
||||
}
|
||||
""")!;
|
||||
|
||||
var rule = NotifySchemaMigration.UpgradeRule(json);
|
||||
|
||||
Assert.Equal(NotifySchemaVersions.Rule, rule.SchemaVersion);
|
||||
Assert.Equal("rule-legacy", rule.RuleId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void UpgradeRuleThrowsOnUnknownSchema()
|
||||
{
|
||||
var json = JsonNode.Parse(
|
||||
"""
|
||||
{
|
||||
"schemaVersion": "notify.rule@2",
|
||||
"ruleId": "rule-future",
|
||||
"tenantId": "tenant-1",
|
||||
"name": "future",
|
||||
"enabled": true,
|
||||
"match": { "eventKinds": ["scanner.report.ready"] },
|
||||
"actions": [ { "actionId": "send", "channel": "email:soc", "enabled": true } ],
|
||||
"createdAt": "2025-10-18T00:00:00Z",
|
||||
"updatedAt": "2025-10-18T00:00:00Z"
|
||||
}
|
||||
""")!;
|
||||
|
||||
var exception = Assert.Throws<NotSupportedException>(() => NotifySchemaMigration.UpgradeRule(json));
|
||||
Assert.Contains("notify rule schema version", exception.Message, StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void UpgradeChannelDefaultsMissingVersion()
|
||||
{
|
||||
var json = JsonNode.Parse(
|
||||
"""
|
||||
{
|
||||
"channelId": "channel-email",
|
||||
"tenantId": "tenant-1",
|
||||
"name": "email:soc",
|
||||
"type": "email",
|
||||
"config": { "secretRef": "ref://notify/channels/email/soc" },
|
||||
"enabled": true,
|
||||
"createdAt": "2025-10-18T00:00:00Z",
|
||||
"updatedAt": "2025-10-18T00:00:00Z"
|
||||
}
|
||||
""")!;
|
||||
|
||||
var channel = NotifySchemaMigration.UpgradeChannel(json);
|
||||
|
||||
Assert.Equal(NotifySchemaVersions.Channel, channel.SchemaVersion);
|
||||
Assert.Equal("channel-email", channel.ChannelId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void UpgradeTemplateDefaultsMissingVersion()
|
||||
{
|
||||
var json = JsonNode.Parse(
|
||||
"""
|
||||
{
|
||||
"templateId": "tmpl-slack-concise",
|
||||
"tenantId": "tenant-1",
|
||||
"channelType": "slack",
|
||||
"key": "concise",
|
||||
"locale": "en-us",
|
||||
"body": "{{summary}}",
|
||||
"renderMode": "markdown",
|
||||
"format": "slack",
|
||||
"createdAt": "2025-10-18T00:00:00Z",
|
||||
"updatedAt": "2025-10-18T00:00:00Z"
|
||||
}
|
||||
""")!;
|
||||
|
||||
var template = NotifySchemaMigration.UpgradeTemplate(json);
|
||||
|
||||
Assert.Equal(NotifySchemaVersions.Template, template.SchemaVersion);
|
||||
Assert.Equal("tmpl-slack-concise", template.TemplateId);
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
using System;
|
||||
using System.IO;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Nodes;
|
||||
using StellaOps.Notify.Models;
|
||||
using Xunit.Sdk;
|
||||
|
||||
namespace StellaOps.Notify.Models.Tests;
|
||||
|
||||
public sealed class PlatformEventSamplesTests
|
||||
{
|
||||
private static readonly JsonSerializerOptions SerializerOptions = new(JsonSerializerDefaults.Web);
|
||||
|
||||
[Theory]
|
||||
[InlineData("scanner.report.ready@1.sample.json", NotifyEventKinds.ScannerReportReady)]
|
||||
[InlineData("scanner.scan.completed@1.sample.json", NotifyEventKinds.ScannerScanCompleted)]
|
||||
[InlineData("scheduler.rescan.delta@1.sample.json", NotifyEventKinds.SchedulerRescanDelta)]
|
||||
[InlineData("attestor.logged@1.sample.json", NotifyEventKinds.AttestorLogged)]
|
||||
public void PlatformEventSamplesRoundtripThroughNotifySerializer(string fileName, string expectedKind)
|
||||
{
|
||||
var json = LoadSample(fileName);
|
||||
var notifyEvent = JsonSerializer.Deserialize<NotifyEvent>(json, SerializerOptions);
|
||||
|
||||
Assert.NotNull(notifyEvent);
|
||||
Assert.Equal(expectedKind, notifyEvent!.Kind);
|
||||
Assert.NotEqual(Guid.Empty, notifyEvent.EventId);
|
||||
Assert.False(string.IsNullOrWhiteSpace(notifyEvent.Tenant));
|
||||
Assert.Equal(TimeSpan.Zero, notifyEvent.Ts.Offset);
|
||||
|
||||
var canonicalJson = NotifyCanonicalJsonSerializer.Serialize(notifyEvent);
|
||||
var canonicalNode = JsonNode.Parse(canonicalJson) ?? throw new InvalidOperationException("Canonical JSON null.");
|
||||
var sampleNode = JsonNode.Parse(json) ?? throw new InvalidOperationException("Sample JSON null.");
|
||||
|
||||
if (!JsonNode.DeepEquals(sampleNode, canonicalNode))
|
||||
{
|
||||
var expected = canonicalNode.ToJsonString(new JsonSerializerOptions { WriteIndented = true });
|
||||
var actual = sampleNode.ToJsonString(new JsonSerializerOptions { WriteIndented = true });
|
||||
throw new Xunit.Sdk.XunitException($"Sample '{fileName}' must remain canonical.\nExpected:\n{expected}\nActual:\n{actual}");
|
||||
}
|
||||
}
|
||||
|
||||
private static string LoadSample(string fileName)
|
||||
{
|
||||
var path = Path.Combine(AppContext.BaseDirectory, fileName);
|
||||
if (!File.Exists(path))
|
||||
{
|
||||
throw new FileNotFoundException($"Unable to locate sample '{fileName}'.", path);
|
||||
}
|
||||
|
||||
return File.ReadAllText(path);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using NJsonSchema;
|
||||
using Xunit;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
namespace StellaOps.Notify.Models.Tests;
|
||||
|
||||
public sealed class PlatformEventSchemaValidationTests
|
||||
{
|
||||
public static IEnumerable<object[]> SampleFiles() => new[]
|
||||
{
|
||||
new object[] { "scanner.report.ready@1.sample.json", "scanner.report.ready@1.json" },
|
||||
new object[] { "scanner.scan.completed@1.sample.json", "scanner.scan.completed@1.json" },
|
||||
new object[] { "scheduler.rescan.delta@1.sample.json", "scheduler.rescan.delta@1.json" },
|
||||
new object[] { "attestor.logged@1.sample.json", "attestor.logged@1.json" }
|
||||
};
|
||||
|
||||
[Theory]
|
||||
[MemberData(nameof(SampleFiles))]
|
||||
public async Task EventSamplesConformToPublishedSchemas(string sampleFile, string schemaFile)
|
||||
{
|
||||
var baseDirectory = AppContext.BaseDirectory;
|
||||
var samplePath = Path.Combine(baseDirectory, sampleFile);
|
||||
var schemaPath = Path.Combine(baseDirectory, schemaFile);
|
||||
|
||||
Assert.True(File.Exists(samplePath), $"Sample '{sampleFile}' not found at '{samplePath}'.");
|
||||
Assert.True(File.Exists(schemaPath), $"Schema '{schemaFile}' not found at '{schemaPath}'.");
|
||||
|
||||
var schema = await JsonSchema.FromJsonAsync(File.ReadAllText(schemaPath));
|
||||
var errors = schema.Validate(File.ReadAllText(samplePath));
|
||||
|
||||
if (errors.Count > 0)
|
||||
{
|
||||
var formatted = string.Join(
|
||||
Environment.NewLine,
|
||||
errors.Select(error => $"{error.Path}: {error.Kind} ({error})"));
|
||||
|
||||
Assert.True(errors.Count == 0, $"Schema validation failed for '{sampleFile}':{Environment.NewLine}{formatted}");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
<?xml version='1.0' encoding='utf-8'?>
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="../../__Libraries/StellaOps.Notify.Models/StellaOps.Notify.Models.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="NJsonSchema" Version="10.9.0" />
|
||||
<None Include="../../docs/events/samples/*.json">
|
||||
<CopyToOutputDirectory>Always</CopyToOutputDirectory>
|
||||
</None>
|
||||
<None Include="../../docs/events/*.json">
|
||||
<CopyToOutputDirectory>Always</CopyToOutputDirectory>
|
||||
</None>
|
||||
<None Include="../../docs/notify/samples/*.json">
|
||||
<CopyToOutputDirectory>Always</CopyToOutputDirectory>
|
||||
</None>
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
Reference in New Issue
Block a user