Restructure solution layout by module
This commit is contained in:
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
{
|
||||
"$schema": "https://xunit.net/schema/current/xunit.runner.schema.json"
|
||||
}
|
||||
Reference in New Issue
Block a user