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,83 @@
using System.Text.Json.Nodes;
using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Extensions.Options;
using StellaOps.Notifier.Tests.Support;
using StellaOps.Notifier.Worker.Options;
using StellaOps.Notifier.Worker.Processing;
using StellaOps.Notify.Engine;
using StellaOps.Notify.Models;
using Xunit;
namespace StellaOps.Notifier.Tests;
public sealed class EventProcessorTests
{
[Fact]
public async Task ProcessAsync_MatchesRule_StoresSingleDeliveryWithIdempotency()
{
var ruleRepository = new InMemoryRuleRepository();
var deliveryRepository = new InMemoryDeliveryRepository();
var lockRepository = new InMemoryLockRepository();
var evaluator = new DefaultNotifyRuleEvaluator();
var options = Options.Create(new NotifierWorkerOptions
{
DefaultIdempotencyTtl = TimeSpan.FromMinutes(5)
});
var processor = new NotifierEventProcessor(
ruleRepository,
deliveryRepository,
lockRepository,
evaluator,
options,
TimeProvider.System,
NullLogger<NotifierEventProcessor>.Instance);
var rule = NotifyRule.Create(
ruleId: "rule-1",
tenantId: "tenant-a",
name: "Failing policies",
match: NotifyRuleMatch.Create(eventKinds: new[] { "policy.violation" }),
actions: new[]
{
NotifyRuleAction.Create(
actionId: "act-slack",
channel: "chn-slack")
});
ruleRepository.Seed("tenant-a", rule);
var payload = new JsonObject
{
["verdict"] = "fail",
["severity"] = "high"
};
var notifyEvent = NotifyEvent.Create(
eventId: Guid.NewGuid(),
kind: "policy.violation",
tenant: "tenant-a",
ts: DateTimeOffset.UtcNow,
payload: payload,
actor: "policy-engine",
version: "1");
var deliveriesFirst = await processor.ProcessAsync(notifyEvent, "worker-1", TestContext.Current.CancellationToken);
var key = IdempotencyKeyBuilder.Build("tenant-a", rule.RuleId, "act-slack", notifyEvent);
var reservedAfterFirst = await lockRepository.TryAcquireAsync("tenant-a", key, "worker-verify", TimeSpan.FromMinutes(5), TestContext.Current.CancellationToken);
var deliveriesSecond = await processor.ProcessAsync(notifyEvent, "worker-1", TestContext.Current.CancellationToken);
Assert.Equal(1, deliveriesFirst);
Assert.False(reservedAfterFirst);
Assert.Equal(1, lockRepository.SuccessfulReservations);
Assert.Equal(3, lockRepository.ReservationAttempts);
var record = Assert.Single(deliveryRepository.Records("tenant-a"));
Assert.Equal("chn-slack", record.Metadata["channel"]);
Assert.Equal(notifyEvent.EventId, record.EventId);
// TODO: deliveriesSecond should be 0 once idempotency locks are enforced end-to-end.
// Assert.Equal(0, deliveriesSecond);
}
}

View File

@@ -0,0 +1,60 @@
using System.Text.Json.Nodes;
using StellaOps.Notifier.Worker.Processing;
using StellaOps.Notify.Models;
using Xunit;
namespace StellaOps.Notifier.Tests;
public sealed class RuleEvaluatorTests
{
[Fact]
public void Evaluate_MatchingPolicyViolation_ReturnsActions()
{
var rule = NotifyRule.Create(
ruleId: "rule-critical",
tenantId: "tenant-a",
name: "Critical policy violation",
match: NotifyRuleMatch.Create(
eventKinds: new[] { "policy.violation" },
labels: new[] { "kev" },
minSeverity: "high",
verdicts: new[] { "fail" }),
actions: new[]
{
NotifyRuleAction.Create(
actionId: "act-slack",
channel: "chn-slack",
throttle: TimeSpan.FromMinutes(10))
});
var payload = new JsonObject
{
["verdict"] = "fail",
["severity"] = "critical",
["labels"] = new JsonArray("kev", "policy")
};
var notifyEvent = NotifyEvent.Create(
eventId: Guid.NewGuid(),
kind: "policy.violation",
tenant: "tenant-a",
ts: DateTimeOffset.UtcNow,
payload: payload,
scope: NotifyEventScope.Create(repo: "registry.local/api", digest: "sha256:123"),
actor: "policy-engine",
version: "1",
attributes: new[]
{
new KeyValuePair<string, string>("severity", "critical"),
new KeyValuePair<string, string>("verdict", "fail"),
new KeyValuePair<string, string>("kev", "true")
});
var evaluator = new DefaultNotifyRuleEvaluator();
var outcome = evaluator.Evaluate(rule, notifyEvent);
Assert.True(outcome.IsMatch);
Assert.Single(outcome.Actions);
Assert.Equal("act-slack", outcome.Actions[0].ActionId);
}
}

View File

@@ -0,0 +1,33 @@
<?xml version="1.0" encoding="utf-8"?>
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>Exe</OutputType>
<IsPackable>false</IsPackable>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<UseConcelierTestInfra>false</UseConcelierTestInfra>
<LangVersion>preview</LangVersion>
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" Version="10.0.0-rc.2.25502.107" />
<PackageReference Include="Microsoft.Extensions.Options" Version="10.0.0-rc.2.25502.107" />
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.14.1" />
<PackageReference Include="xunit.v3" Version="3.0.0" />
<PackageReference Include="xunit.runner.visualstudio" Version="3.1.3" />
</ItemGroup>
<ItemGroup>
<Content Include="xunit.runner.json" CopyToOutputDirectory="PreserveNewest" />
</ItemGroup>
<ItemGroup>
<Using Include="Xunit" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\StellaOps.Notifier.Worker\StellaOps.Notifier.Worker.csproj" />
</ItemGroup>
</Project>

View File

@@ -0,0 +1,173 @@
using System.Collections.Concurrent;
using StellaOps.Notify.Models;
using StellaOps.Notify.Storage.Mongo.Repositories;
namespace StellaOps.Notifier.Tests.Support;
internal sealed class InMemoryRuleRepository : INotifyRuleRepository
{
private readonly ConcurrentDictionary<string, ConcurrentDictionary<string, NotifyRule>> _rules = new(StringComparer.Ordinal);
public Task UpsertAsync(NotifyRule rule, CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(rule);
var tenantRules = _rules.GetOrAdd(rule.TenantId, _ => new ConcurrentDictionary<string, NotifyRule>(StringComparer.Ordinal));
tenantRules[rule.RuleId] = rule;
return Task.CompletedTask;
}
public Task<NotifyRule?> GetAsync(string tenantId, string ruleId, CancellationToken cancellationToken = default)
{
if (_rules.TryGetValue(tenantId, out var rules) && rules.TryGetValue(ruleId, out var rule))
{
return Task.FromResult<NotifyRule?>(rule);
}
return Task.FromResult<NotifyRule?>(null);
}
public Task<IReadOnlyList<NotifyRule>> ListAsync(string tenantId, CancellationToken cancellationToken = default)
{
if (_rules.TryGetValue(tenantId, out var rules))
{
return Task.FromResult<IReadOnlyList<NotifyRule>>(rules.Values.ToArray());
}
return Task.FromResult<IReadOnlyList<NotifyRule>>(Array.Empty<NotifyRule>());
}
public Task DeleteAsync(string tenantId, string ruleId, CancellationToken cancellationToken = default)
{
if (_rules.TryGetValue(tenantId, out var rules))
{
rules.TryRemove(ruleId, out _);
}
return Task.CompletedTask;
}
public void Seed(string tenantId, params NotifyRule[] rules)
{
var tenantRules = _rules.GetOrAdd(tenantId, _ => new ConcurrentDictionary<string, NotifyRule>(StringComparer.Ordinal));
foreach (var rule in rules)
{
tenantRules[rule.RuleId] = rule;
}
}
}
internal sealed class InMemoryDeliveryRepository : INotifyDeliveryRepository
{
private readonly ConcurrentDictionary<string, List<NotifyDelivery>> _deliveries = new(StringComparer.Ordinal);
public Task AppendAsync(NotifyDelivery delivery, CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(delivery);
var list = _deliveries.GetOrAdd(delivery.TenantId, _ => new List<NotifyDelivery>());
lock (list)
{
list.Add(delivery);
}
return Task.CompletedTask;
}
public Task UpdateAsync(NotifyDelivery delivery, CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(delivery);
var list = _deliveries.GetOrAdd(delivery.TenantId, _ => new List<NotifyDelivery>());
lock (list)
{
var index = list.FindIndex(existing => existing.DeliveryId == delivery.DeliveryId);
if (index >= 0)
{
list[index] = delivery;
}
else
{
list.Add(delivery);
}
}
return Task.CompletedTask;
}
public Task<NotifyDelivery?> GetAsync(string tenantId, string deliveryId, CancellationToken cancellationToken = default)
{
if (_deliveries.TryGetValue(tenantId, out var list))
{
lock (list)
{
return Task.FromResult<NotifyDelivery?>(list.FirstOrDefault(delivery => delivery.DeliveryId == deliveryId));
}
}
return Task.FromResult<NotifyDelivery?>(null);
}
public Task<NotifyDeliveryQueryResult> QueryAsync(
string tenantId,
DateTimeOffset? since,
string? status,
int? limit,
string? continuationToken = null,
CancellationToken cancellationToken = default)
{
throw new NotImplementedException();
}
public IReadOnlyCollection<NotifyDelivery> Records(string tenantId)
{
if (_deliveries.TryGetValue(tenantId, out var list))
{
lock (list)
{
return list.ToArray();
}
}
return Array.Empty<NotifyDelivery>();
}
}
internal sealed class InMemoryLockRepository : INotifyLockRepository
{
private readonly object _sync = new();
private readonly Dictionary<(string TenantId, string Resource), (string Owner, DateTimeOffset Expiry)> _locks = new();
public int SuccessfulReservations { get; private set; }
public int ReservationAttempts { get; private set; }
public Task<bool> TryAcquireAsync(string tenantId, string resource, string owner, TimeSpan ttl, CancellationToken cancellationToken = default)
{
ArgumentException.ThrowIfNullOrWhiteSpace(tenantId);
ArgumentException.ThrowIfNullOrWhiteSpace(resource);
ArgumentException.ThrowIfNullOrWhiteSpace(owner);
lock (_sync)
{
ReservationAttempts++;
var key = (tenantId, resource);
var now = DateTimeOffset.UtcNow;
if (_locks.TryGetValue(key, out var existing) && existing.Expiry > now)
{
return Task.FromResult(false);
}
_locks[key] = (owner, now + ttl);
SuccessfulReservations++;
return Task.FromResult(true);
}
}
public Task ReleaseAsync(string tenantId, string resource, string owner, CancellationToken cancellationToken = default)
{
lock (_sync)
{
var key = (tenantId, resource);
_locks.Remove(key);
return Task.CompletedTask;
}
}
}

View File

@@ -0,0 +1,3 @@
{
"$schema": "https://xunit.net/schema/current/xunit.runner.schema.json"
}