Restructure solution layout by module

This commit is contained in:
master
2025-10-28 15:10:40 +02:00
parent 95daa159c4
commit d870da18ce
4103 changed files with 192899 additions and 187024 deletions

View File

@@ -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);
}
}

View File

@@ -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.");
}
}

View File

@@ -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);
}
}

View File

@@ -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);
}
}

View File

@@ -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);
}
}

View File

@@ -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);
}
}

View File

@@ -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}");
}
}
}

View File

@@ -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>