Restructure solution layout by module
This commit is contained in:
@@ -0,0 +1,100 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using StellaOps.Notify.Engine;
|
||||
using StellaOps.Notify.Models;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Notify.Connectors.Email.Tests;
|
||||
|
||||
public sealed class EmailChannelHealthProviderTests
|
||||
{
|
||||
private static readonly EmailChannelHealthProvider Provider = new();
|
||||
|
||||
[Fact]
|
||||
public async Task CheckAsync_ReturnsHealthy()
|
||||
{
|
||||
var channel = CreateChannel(enabled: true, target: "ops@example.com");
|
||||
|
||||
var context = new ChannelHealthContext(
|
||||
channel.TenantId,
|
||||
channel,
|
||||
channel.Config.Target!,
|
||||
new DateTimeOffset(2025, 10, 20, 15, 0, 0, TimeSpan.Zero),
|
||||
"trace-email-001");
|
||||
|
||||
var result = await Provider.CheckAsync(context, CancellationToken.None);
|
||||
|
||||
Assert.Equal(ChannelHealthStatus.Healthy, result.Status);
|
||||
Assert.Equal("true", result.Metadata["email.channel.enabled"]);
|
||||
Assert.Equal("true", result.Metadata["email.validation.targetPresent"]);
|
||||
Assert.Equal("ops@example.com", result.Metadata["email.target"]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CheckAsync_ReturnsDegradedWhenDisabled()
|
||||
{
|
||||
var channel = CreateChannel(enabled: false, target: "ops@example.com");
|
||||
|
||||
var context = new ChannelHealthContext(
|
||||
channel.TenantId,
|
||||
channel,
|
||||
channel.Config.Target!,
|
||||
DateTimeOffset.UtcNow,
|
||||
"trace-email-002");
|
||||
|
||||
var result = await Provider.CheckAsync(context, CancellationToken.None);
|
||||
|
||||
Assert.Equal(ChannelHealthStatus.Degraded, result.Status);
|
||||
Assert.Equal("false", result.Metadata["email.channel.enabled"]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CheckAsync_ReturnsUnhealthyWhenTargetMissing()
|
||||
{
|
||||
var channel = NotifyChannel.Create(
|
||||
channelId: "channel-email-ops",
|
||||
tenantId: "tenant-sec",
|
||||
name: "email:ops",
|
||||
type: NotifyChannelType.Email,
|
||||
config: NotifyChannelConfig.Create(
|
||||
secretRef: "ref://notify/channels/email/ops",
|
||||
target: null,
|
||||
properties: new Dictionary<string, string>
|
||||
{
|
||||
["smtpHost"] = "smtp.ops.example.com"
|
||||
}),
|
||||
enabled: true);
|
||||
|
||||
var context = new ChannelHealthContext(
|
||||
channel.TenantId,
|
||||
channel,
|
||||
channel.Name,
|
||||
DateTimeOffset.UtcNow,
|
||||
"trace-email-003");
|
||||
|
||||
var result = await Provider.CheckAsync(context, CancellationToken.None);
|
||||
|
||||
Assert.Equal(ChannelHealthStatus.Unhealthy, result.Status);
|
||||
Assert.Equal("false", result.Metadata["email.validation.targetPresent"]);
|
||||
}
|
||||
|
||||
private static NotifyChannel CreateChannel(bool enabled, string? target)
|
||||
{
|
||||
return NotifyChannel.Create(
|
||||
channelId: "channel-email-ops",
|
||||
tenantId: "tenant-sec",
|
||||
name: "email:ops",
|
||||
type: NotifyChannelType.Email,
|
||||
config: NotifyChannelConfig.Create(
|
||||
secretRef: "ref://notify/channels/email/ops",
|
||||
target: target,
|
||||
properties: new Dictionary<string, string>
|
||||
{
|
||||
["smtpHost"] = "smtp.ops.example.com",
|
||||
["password"] = "super-secret"
|
||||
}),
|
||||
enabled: enabled);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
<?xml version='1.0' encoding='utf-8'?>
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
<UseConcelierTestInfra>false</UseConcelierTestInfra>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="../../__Libraries/StellaOps.Notify.Connectors.Email/StellaOps.Notify.Connectors.Email.csproj" />
|
||||
<ProjectReference Include="../../__Libraries/StellaOps.Notify.Engine/StellaOps.Notify.Engine.csproj" />
|
||||
<ProjectReference Include="../../__Libraries/StellaOps.Notify.Models/StellaOps.Notify.Models.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.14.0" />
|
||||
<PackageReference Include="xunit" Version="2.9.2" />
|
||||
<PackageReference Include="xunit.runner.visualstudio" Version="2.8.2" />
|
||||
<PackageReference Include="coverlet.collector" Version="6.0.4" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
@@ -0,0 +1,96 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using StellaOps.Notify.Engine;
|
||||
using StellaOps.Notify.Models;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Notify.Connectors.Slack.Tests;
|
||||
|
||||
public sealed class SlackChannelHealthProviderTests
|
||||
{
|
||||
private static readonly SlackChannelHealthProvider Provider = new();
|
||||
|
||||
[Fact]
|
||||
public async Task CheckAsync_ReturnsHealthy()
|
||||
{
|
||||
var channel = CreateChannel(enabled: true, target: "#sec-ops");
|
||||
|
||||
var context = new ChannelHealthContext(
|
||||
channel.TenantId,
|
||||
channel,
|
||||
channel.Config.Target!,
|
||||
new DateTimeOffset(2025, 10, 20, 14, 0, 0, TimeSpan.Zero),
|
||||
"trace-slack-001");
|
||||
|
||||
var result = await Provider.CheckAsync(context, CancellationToken.None);
|
||||
|
||||
Assert.Equal(ChannelHealthStatus.Healthy, result.Status);
|
||||
Assert.Equal("true", result.Metadata["slack.channel.enabled"]);
|
||||
Assert.Equal("true", result.Metadata["slack.validation.targetPresent"]);
|
||||
Assert.Equal("#sec-ops", result.Metadata["slack.channel"]);
|
||||
Assert.Equal(ComputeSecretHash(channel.Config.SecretRef), result.Metadata["slack.secretRef.hash"]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CheckAsync_ReturnsDegradedWhenDisabled()
|
||||
{
|
||||
var channel = CreateChannel(enabled: false, target: "#sec-ops");
|
||||
|
||||
var context = new ChannelHealthContext(
|
||||
channel.TenantId,
|
||||
channel,
|
||||
channel.Config.Target!,
|
||||
DateTimeOffset.UtcNow,
|
||||
"trace-slack-002");
|
||||
|
||||
var result = await Provider.CheckAsync(context, CancellationToken.None);
|
||||
|
||||
Assert.Equal(ChannelHealthStatus.Degraded, result.Status);
|
||||
Assert.Equal("false", result.Metadata["slack.channel.enabled"]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CheckAsync_ReturnsUnhealthyWhenTargetMissing()
|
||||
{
|
||||
var channel = CreateChannel(enabled: true, target: null);
|
||||
|
||||
var context = new ChannelHealthContext(
|
||||
channel.TenantId,
|
||||
channel,
|
||||
channel.Name,
|
||||
DateTimeOffset.UtcNow,
|
||||
"trace-slack-003");
|
||||
|
||||
var result = await Provider.CheckAsync(context, CancellationToken.None);
|
||||
|
||||
Assert.Equal(ChannelHealthStatus.Unhealthy, result.Status);
|
||||
Assert.Equal("false", result.Metadata["slack.validation.targetPresent"]);
|
||||
}
|
||||
|
||||
private static NotifyChannel CreateChannel(bool enabled, string? target)
|
||||
{
|
||||
return NotifyChannel.Create(
|
||||
channelId: "channel-slack-sec-ops",
|
||||
tenantId: "tenant-sec",
|
||||
name: "slack:sec-ops",
|
||||
type: NotifyChannelType.Slack,
|
||||
config: NotifyChannelConfig.Create(
|
||||
secretRef: "ref://notify/channels/slack/sec-ops",
|
||||
target: target,
|
||||
properties: new Dictionary<string, string>
|
||||
{
|
||||
["workspace"] = "stellaops-sec",
|
||||
["botToken"] = "xoxb-123456789012-abcdefghijklmnop"
|
||||
}),
|
||||
enabled: enabled);
|
||||
}
|
||||
|
||||
private static string ComputeSecretHash(string secretRef)
|
||||
{
|
||||
var bytes = System.Text.Encoding.UTF8.GetBytes(secretRef.Trim());
|
||||
var hash = System.Security.Cryptography.SHA256.HashData(bytes);
|
||||
return Convert.ToHexString(hash.AsSpan(0, 8)).ToLowerInvariant();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,113 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Text.Json;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using StellaOps.Notify.Engine;
|
||||
using StellaOps.Notify.Models;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Notify.Connectors.Slack.Tests;
|
||||
|
||||
public sealed class SlackChannelTestProviderTests
|
||||
{
|
||||
private static readonly ChannelTestPreviewRequest EmptyRequest = new(
|
||||
TargetOverride: null,
|
||||
TemplateId: null,
|
||||
Title: null,
|
||||
Summary: null,
|
||||
Body: null,
|
||||
TextBody: null,
|
||||
Locale: null,
|
||||
Metadata: new Dictionary<string, string>(),
|
||||
Attachments: new List<string>());
|
||||
|
||||
[Fact]
|
||||
public async Task BuildPreviewAsync_ProducesDeterministicMetadata()
|
||||
{
|
||||
var provider = new SlackChannelTestProvider();
|
||||
var channel = CreateChannel(properties: new Dictionary<string, string>
|
||||
{
|
||||
["workspace"] = "stellaops-sec",
|
||||
["botToken"] = "xoxb-123456789012-abcdefghijklmnop"
|
||||
});
|
||||
|
||||
var context = new ChannelTestPreviewContext(
|
||||
channel.TenantId,
|
||||
channel,
|
||||
channel.Config.Target!,
|
||||
EmptyRequest,
|
||||
Timestamp: new DateTimeOffset(2025, 10, 20, 12, 00, 00, TimeSpan.Zero),
|
||||
TraceId: "trace-001");
|
||||
|
||||
var result = await provider.BuildPreviewAsync(context, CancellationToken.None);
|
||||
|
||||
Assert.Equal("slack", result.Preview.ChannelType.ToString().ToLowerInvariant());
|
||||
Assert.Equal(channel.Config.Target, result.Preview.Target);
|
||||
Assert.Equal("chat:write,chat:write.public", result.Metadata["slack.scopes.required"]);
|
||||
Assert.Equal("stellaops-sec", result.Metadata["slack.config.workspace"]);
|
||||
|
||||
var redactedToken = result.Metadata["slack.config.botToken"];
|
||||
Assert.DoesNotContain("abcdefghijklmnop", redactedToken);
|
||||
Assert.StartsWith("xoxb-", redactedToken);
|
||||
Assert.EndsWith("mnop", redactedToken);
|
||||
|
||||
using var parsed = JsonDocument.Parse(result.Preview.Body);
|
||||
var contextText = parsed.RootElement
|
||||
.GetProperty("blocks")[1]
|
||||
.GetProperty("elements")[0]
|
||||
.GetProperty("text")
|
||||
.GetString();
|
||||
Assert.NotNull(contextText);
|
||||
Assert.Contains("trace-001", contextText);
|
||||
|
||||
Assert.Equal(ComputeSecretHash(channel.Config.SecretRef), result.Metadata["slack.secretRef.hash"]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task BuildPreviewAsync_RedactsSensitiveProperties()
|
||||
{
|
||||
var provider = new SlackChannelTestProvider();
|
||||
var channel = CreateChannel(properties: new Dictionary<string, string>
|
||||
{
|
||||
["SigningSecret"] = "whsec_super-secret-value",
|
||||
["apiToken"] = "xoxs-000000000000-super",
|
||||
["endpoint"] = "https://hooks.slack.com/services/T000/B000/AAA"
|
||||
});
|
||||
|
||||
var context = new ChannelTestPreviewContext(
|
||||
channel.TenantId,
|
||||
channel,
|
||||
channel.Config.Target!,
|
||||
EmptyRequest,
|
||||
Timestamp: DateTimeOffset.UtcNow,
|
||||
TraceId: "trace-002");
|
||||
|
||||
var result = await provider.BuildPreviewAsync(context, CancellationToken.None);
|
||||
|
||||
Assert.Equal("***", result.Metadata["slack.config.SigningSecret"]);
|
||||
Assert.DoesNotContain("xoxs-000000000000-super", result.Metadata["slack.config.apiToken"]);
|
||||
Assert.Equal("https://hooks.slack.com/services/T000/B000/AAA", result.Metadata["slack.config.endpoint"]);
|
||||
}
|
||||
|
||||
private static NotifyChannel CreateChannel(IDictionary<string, string> properties)
|
||||
{
|
||||
return NotifyChannel.Create(
|
||||
channelId: "channel-slack-sec-ops",
|
||||
tenantId: "tenant-sec",
|
||||
name: "slack:sec-ops",
|
||||
type: NotifyChannelType.Slack,
|
||||
config: NotifyChannelConfig.Create(
|
||||
secretRef: "ref://notify/channels/slack/sec-ops",
|
||||
target: "#sec-ops",
|
||||
properties: properties));
|
||||
}
|
||||
|
||||
private static string ComputeSecretHash(string secretRef)
|
||||
{
|
||||
using var sha = System.Security.Cryptography.SHA256.Create();
|
||||
var bytes = System.Text.Encoding.UTF8.GetBytes(secretRef.Trim());
|
||||
var hash = sha.ComputeHash(bytes);
|
||||
return System.Convert.ToHexString(hash, 0, 8).ToLowerInvariant();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
<?xml version='1.0' encoding='utf-8'?>
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
<UseConcelierTestInfra>false</UseConcelierTestInfra>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="../../__Libraries/StellaOps.Notify.Connectors.Slack/StellaOps.Notify.Connectors.Slack.csproj" />
|
||||
<ProjectReference Include="../../__Libraries/StellaOps.Notify.Engine/StellaOps.Notify.Engine.csproj" />
|
||||
<ProjectReference Include="../../__Libraries/StellaOps.Notify.Models/StellaOps.Notify.Models.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.14.0" />
|
||||
<PackageReference Include="xunit" Version="2.9.2" />
|
||||
<PackageReference Include="xunit.runner.visualstudio" Version="2.8.2" />
|
||||
<PackageReference Include="coverlet.collector" Version="6.0.4" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
@@ -0,0 +1,22 @@
|
||||
<?xml version='1.0' encoding='utf-8'?>
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
<UseConcelierTestInfra>false</UseConcelierTestInfra>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="../../__Libraries/StellaOps.Notify.Connectors.Teams/StellaOps.Notify.Connectors.Teams.csproj" />
|
||||
<ProjectReference Include="../../__Libraries/StellaOps.Notify.Engine/StellaOps.Notify.Engine.csproj" />
|
||||
<ProjectReference Include="../../__Libraries/StellaOps.Notify.Models/StellaOps.Notify.Models.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.14.0" />
|
||||
<PackageReference Include="xunit" Version="2.9.2" />
|
||||
<PackageReference Include="xunit.runner.visualstudio" Version="2.8.2" />
|
||||
<PackageReference Include="coverlet.collector" Version="6.0.4" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
@@ -0,0 +1,98 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using StellaOps.Notify.Engine;
|
||||
using StellaOps.Notify.Models;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Notify.Connectors.Teams.Tests;
|
||||
|
||||
public sealed class TeamsChannelHealthProviderTests
|
||||
{
|
||||
private static readonly TeamsChannelHealthProvider Provider = new();
|
||||
|
||||
[Fact]
|
||||
public async Task CheckAsync_ReturnsHealthyWithMetadata()
|
||||
{
|
||||
var channel = CreateChannel(enabled: true, endpoint: "https://contoso.webhook.office.com/webhook");
|
||||
|
||||
var context = new ChannelHealthContext(
|
||||
channel.TenantId,
|
||||
channel,
|
||||
channel.Config.Endpoint!,
|
||||
new DateTimeOffset(2025, 10, 20, 12, 0, 0, TimeSpan.Zero),
|
||||
"trace-health-001");
|
||||
|
||||
var result = await Provider.CheckAsync(context, CancellationToken.None);
|
||||
|
||||
Assert.Equal(ChannelHealthStatus.Healthy, result.Status);
|
||||
Assert.Equal("Teams channel configuration validated.", result.Message);
|
||||
Assert.Equal("true", result.Metadata["teams.channel.enabled"]);
|
||||
Assert.Equal("true", result.Metadata["teams.validation.targetPresent"]);
|
||||
Assert.Equal(channel.Config.Endpoint, result.Metadata["teams.webhook"]);
|
||||
Assert.Equal(ComputeSecretHash(channel.Config.SecretRef), result.Metadata["teams.secretRef.hash"]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CheckAsync_ReturnsDegradedWhenDisabled()
|
||||
{
|
||||
var channel = CreateChannel(enabled: false, endpoint: "https://contoso.webhook.office.com/webhook");
|
||||
|
||||
var context = new ChannelHealthContext(
|
||||
channel.TenantId,
|
||||
channel,
|
||||
channel.Config.Endpoint!,
|
||||
DateTimeOffset.UtcNow,
|
||||
"trace-health-002");
|
||||
|
||||
var result = await Provider.CheckAsync(context, CancellationToken.None);
|
||||
|
||||
Assert.Equal(ChannelHealthStatus.Degraded, result.Status);
|
||||
Assert.Equal("false", result.Metadata["teams.channel.enabled"]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CheckAsync_ReturnsUnhealthyWhenTargetMissing()
|
||||
{
|
||||
var channel = CreateChannel(enabled: true, endpoint: null);
|
||||
|
||||
var context = new ChannelHealthContext(
|
||||
channel.TenantId,
|
||||
channel,
|
||||
channel.Name,
|
||||
DateTimeOffset.UtcNow,
|
||||
"trace-health-003");
|
||||
|
||||
var result = await Provider.CheckAsync(context, CancellationToken.None);
|
||||
|
||||
Assert.Equal(ChannelHealthStatus.Unhealthy, result.Status);
|
||||
Assert.Equal("false", result.Metadata["teams.validation.targetPresent"]);
|
||||
}
|
||||
|
||||
private static NotifyChannel CreateChannel(bool enabled, string? endpoint)
|
||||
{
|
||||
return NotifyChannel.Create(
|
||||
channelId: "channel-teams-sec-ops",
|
||||
tenantId: "tenant-sec",
|
||||
name: "teams:sec-ops",
|
||||
type: NotifyChannelType.Teams,
|
||||
config: NotifyChannelConfig.Create(
|
||||
secretRef: "ref://notify/channels/teams/sec-ops",
|
||||
target: null,
|
||||
endpoint: endpoint,
|
||||
properties: new Dictionary<string, string>
|
||||
{
|
||||
["tenant"] = "contoso.onmicrosoft.com",
|
||||
["webhookKey"] = "abcdef0123456789"
|
||||
}),
|
||||
enabled: enabled);
|
||||
}
|
||||
|
||||
private static string ComputeSecretHash(string secretRef)
|
||||
{
|
||||
var bytes = System.Text.Encoding.UTF8.GetBytes(secretRef.Trim());
|
||||
var hash = System.Security.Cryptography.SHA256.HashData(bytes);
|
||||
return Convert.ToHexString(hash.AsSpan(0, 8)).ToLowerInvariant();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,135 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Text.Json;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using StellaOps.Notify.Engine;
|
||||
using StellaOps.Notify.Models;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Notify.Connectors.Teams.Tests;
|
||||
|
||||
public sealed class TeamsChannelTestProviderTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task BuildPreviewAsync_EmitsFallbackMetadata()
|
||||
{
|
||||
var provider = new TeamsChannelTestProvider();
|
||||
var channel = CreateChannel(
|
||||
endpoint: "https://contoso.webhook.office.com/webhookb2/tenant@uuid/IncomingWebhook/abcdef0123456789",
|
||||
properties: new Dictionary<string, string>
|
||||
{
|
||||
["team"] = "secops",
|
||||
["webhookKey"] = "s3cr3t-value-with-key-fragment",
|
||||
["tenant"] = "contoso.onmicrosoft.com"
|
||||
});
|
||||
|
||||
var request = new ChannelTestPreviewRequest(
|
||||
TargetOverride: null,
|
||||
TemplateId: null,
|
||||
Title: "Notify Critical Finding",
|
||||
Summary: "Critical container vulnerability detected.",
|
||||
Body: "CVSS 9.8 vulnerability detected in ubuntu:22.04 base layer.",
|
||||
TextBody: null,
|
||||
Locale: "en-US",
|
||||
Metadata: new Dictionary<string, string>(),
|
||||
Attachments: new List<string>());
|
||||
|
||||
var context = new ChannelTestPreviewContext(
|
||||
channel.TenantId,
|
||||
channel,
|
||||
channel.Config.Endpoint!,
|
||||
request,
|
||||
new DateTimeOffset(2025, 10, 20, 10, 0, 0, TimeSpan.Zero),
|
||||
TraceId: "trace-teams-001");
|
||||
|
||||
var result = await provider.BuildPreviewAsync(context, CancellationToken.None);
|
||||
|
||||
Assert.Equal(NotifyChannelType.Teams, result.Preview.ChannelType);
|
||||
Assert.Equal(channel.Config.Endpoint, result.Preview.Target);
|
||||
Assert.Equal("Critical container vulnerability detected.", result.Preview.Summary);
|
||||
|
||||
Assert.NotNull(result.Metadata);
|
||||
Assert.Equal(channel.Config.Endpoint, result.Metadata["teams.webhook"]);
|
||||
Assert.Equal("1.5", result.Metadata["teams.card.version"]);
|
||||
|
||||
var fallback = result.Metadata["teams.fallbackText"];
|
||||
Assert.Equal(result.Preview.TextBody, fallback);
|
||||
Assert.Equal("Critical container vulnerability detected.", fallback);
|
||||
|
||||
Assert.Equal(ComputeSecretHash(channel.Config.SecretRef), result.Metadata["teams.secretRef.hash"]);
|
||||
Assert.Equal("***", result.Metadata["teams.config.webhookKey"]);
|
||||
Assert.Equal("contoso.onmicrosoft.com", result.Metadata["teams.config.tenant"]);
|
||||
Assert.Equal(channel.Config.Endpoint, result.Metadata["teams.config.endpoint"]);
|
||||
|
||||
using var payload = JsonDocument.Parse(result.Preview.Body);
|
||||
Assert.Equal("message", payload.RootElement.GetProperty("type").GetString());
|
||||
Assert.Equal(result.Preview.TextBody, payload.RootElement.GetProperty("text").GetString());
|
||||
Assert.Equal(result.Preview.Summary, payload.RootElement.GetProperty("summary").GetString());
|
||||
|
||||
var attachments = payload.RootElement.GetProperty("attachments");
|
||||
Assert.True(attachments.GetArrayLength() > 0);
|
||||
Assert.Equal(
|
||||
"AdaptiveCard",
|
||||
attachments[0].GetProperty("content").GetProperty("type").GetString());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task BuildPreviewAsync_TruncatesLongFallback()
|
||||
{
|
||||
var provider = new TeamsChannelTestProvider();
|
||||
var channel = CreateChannel(
|
||||
endpoint: "https://contoso.webhook.office.com/webhookb2/tenant@uuid/IncomingWebhook/abcdef0123456789",
|
||||
properties: new Dictionary<string, string>());
|
||||
|
||||
var longText = new string('A', 600);
|
||||
|
||||
var request = new ChannelTestPreviewRequest(
|
||||
TargetOverride: null,
|
||||
TemplateId: null,
|
||||
Title: null,
|
||||
Summary: null,
|
||||
Body: null,
|
||||
TextBody: longText,
|
||||
Locale: null,
|
||||
Metadata: new Dictionary<string, string>(),
|
||||
Attachments: new List<string>());
|
||||
|
||||
var context = new ChannelTestPreviewContext(
|
||||
channel.TenantId,
|
||||
channel,
|
||||
channel.Config.Endpoint!,
|
||||
request,
|
||||
DateTimeOffset.UtcNow,
|
||||
TraceId: "trace-teams-002");
|
||||
|
||||
var result = await provider.BuildPreviewAsync(context, CancellationToken.None);
|
||||
|
||||
var metadata = Assert.IsAssignableFrom<IReadOnlyDictionary<string, string>>(result.Metadata);
|
||||
var fallback = Assert.IsType<string>(result.Preview.TextBody);
|
||||
Assert.Equal(512, fallback.Length);
|
||||
Assert.Equal(fallback, metadata["teams.fallbackText"]);
|
||||
Assert.StartsWith(new string('A', 512), fallback);
|
||||
}
|
||||
|
||||
private static NotifyChannel CreateChannel(string endpoint, IDictionary<string, string> properties)
|
||||
{
|
||||
return NotifyChannel.Create(
|
||||
channelId: "channel-teams-sec-ops",
|
||||
tenantId: "tenant-sec",
|
||||
name: "teams:sec-ops",
|
||||
type: NotifyChannelType.Teams,
|
||||
config: NotifyChannelConfig.Create(
|
||||
secretRef: "ref://notify/channels/teams/sec-ops",
|
||||
target: null,
|
||||
endpoint: endpoint,
|
||||
properties: properties));
|
||||
}
|
||||
|
||||
private static string ComputeSecretHash(string secretRef)
|
||||
{
|
||||
var bytes = System.Text.Encoding.UTF8.GetBytes(secretRef.Trim());
|
||||
var hash = System.Security.Cryptography.SHA256.HashData(bytes);
|
||||
return Convert.ToHexString(hash.AsSpan(0, 8)).ToLowerInvariant();
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
@@ -0,0 +1,223 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Text.Json.Nodes;
|
||||
using System.Threading.Tasks;
|
||||
using DotNet.Testcontainers.Builders;
|
||||
using DotNet.Testcontainers.Containers;
|
||||
using DotNet.Testcontainers.Configurations;
|
||||
using FluentAssertions;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using NATS.Client.Core;
|
||||
using NATS.Client.JetStream;
|
||||
using NATS.Client.JetStream.Models;
|
||||
using StellaOps.Notify.Models;
|
||||
using StellaOps.Notify.Queue;
|
||||
using StellaOps.Notify.Queue.Nats;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Notify.Queue.Tests;
|
||||
|
||||
public sealed class NatsNotifyDeliveryQueueTests : IAsyncLifetime
|
||||
{
|
||||
private readonly TestcontainersContainer _nats;
|
||||
private string? _skipReason;
|
||||
|
||||
public NatsNotifyDeliveryQueueTests()
|
||||
{
|
||||
_nats = new TestcontainersBuilder<TestcontainersContainer>()
|
||||
.WithImage("nats:2.10-alpine")
|
||||
.WithCleanUp(true)
|
||||
.WithName($"nats-notify-delivery-{Guid.NewGuid():N}")
|
||||
.WithPortBinding(4222, true)
|
||||
.WithCommand("--jetstream")
|
||||
.WithWaitStrategy(Wait.ForUnixContainer().UntilPortIsAvailable(4222))
|
||||
.Build();
|
||||
}
|
||||
|
||||
public async Task InitializeAsync()
|
||||
{
|
||||
try
|
||||
{
|
||||
await _nats.StartAsync();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_skipReason = $"NATS-backed delivery tests skipped: {ex.Message}";
|
||||
}
|
||||
}
|
||||
|
||||
public async Task DisposeAsync()
|
||||
{
|
||||
if (_skipReason is not null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
await _nats.DisposeAsync().ConfigureAwait(false);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Publish_ShouldDeduplicate_ByDeliveryId()
|
||||
{
|
||||
if (SkipIfUnavailable())
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var options = CreateOptions();
|
||||
await using var queue = CreateQueue(options);
|
||||
|
||||
var delivery = TestData.CreateDelivery("tenant-a");
|
||||
var message = new NotifyDeliveryQueueMessage(
|
||||
delivery,
|
||||
channelId: "chan-a",
|
||||
channelType: NotifyChannelType.Slack);
|
||||
|
||||
var first = await queue.PublishAsync(message);
|
||||
first.Deduplicated.Should().BeFalse();
|
||||
|
||||
var second = await queue.PublishAsync(message);
|
||||
second.Deduplicated.Should().BeTrue();
|
||||
second.MessageId.Should().Be(first.MessageId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Release_Retry_ShouldReschedule()
|
||||
{
|
||||
if (SkipIfUnavailable())
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var options = CreateOptions();
|
||||
await using var queue = CreateQueue(options);
|
||||
|
||||
await queue.PublishAsync(new NotifyDeliveryQueueMessage(
|
||||
TestData.CreateDelivery(),
|
||||
channelId: "chan-retry",
|
||||
channelType: NotifyChannelType.Teams));
|
||||
|
||||
var lease = (await queue.LeaseAsync(new NotifyQueueLeaseRequest("worker-retry", 1, TimeSpan.FromSeconds(2)))).Single();
|
||||
|
||||
await lease.ReleaseAsync(NotifyQueueReleaseDisposition.Retry);
|
||||
|
||||
var retried = (await queue.LeaseAsync(new NotifyQueueLeaseRequest("worker-retry", 1, TimeSpan.FromSeconds(2)))).Single();
|
||||
retried.Attempt.Should().BeGreaterThan(lease.Attempt);
|
||||
|
||||
await retried.AcknowledgeAsync();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Release_RetryBeyondMax_ShouldDeadLetter()
|
||||
{
|
||||
if (SkipIfUnavailable())
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var options = CreateOptions(static opts =>
|
||||
{
|
||||
opts.MaxDeliveryAttempts = 2;
|
||||
opts.Nats.DeadLetterStream = "NOTIFY_DELIVERY_DEAD_TEST";
|
||||
opts.Nats.DeadLetterSubject = "notify.delivery.dead.test";
|
||||
});
|
||||
|
||||
await using var queue = CreateQueue(options);
|
||||
|
||||
await queue.PublishAsync(new NotifyDeliveryQueueMessage(
|
||||
TestData.CreateDelivery(),
|
||||
channelId: "chan-dead",
|
||||
channelType: NotifyChannelType.Webhook));
|
||||
|
||||
var lease = (await queue.LeaseAsync(new NotifyQueueLeaseRequest("worker-dead", 1, TimeSpan.FromSeconds(2)))).Single();
|
||||
await lease.ReleaseAsync(NotifyQueueReleaseDisposition.Retry);
|
||||
|
||||
var second = (await queue.LeaseAsync(new NotifyQueueLeaseRequest("worker-dead", 1, TimeSpan.FromSeconds(2)))).Single();
|
||||
await second.ReleaseAsync(NotifyQueueReleaseDisposition.Retry);
|
||||
|
||||
await Task.Delay(200);
|
||||
|
||||
await using var connection = new NatsConnection(new NatsOpts { Url = options.Nats.Url! });
|
||||
await connection.ConnectAsync();
|
||||
var js = new NatsJSContext(connection);
|
||||
|
||||
var consumerConfig = new ConsumerConfig
|
||||
{
|
||||
DurableName = "notify-delivery-dead-test",
|
||||
DeliverPolicy = ConsumerConfigDeliverPolicy.All,
|
||||
AckPolicy = ConsumerConfigAckPolicy.Explicit
|
||||
};
|
||||
|
||||
var consumer = await js.CreateConsumerAsync(options.Nats.DeadLetterStream, consumerConfig);
|
||||
var fetchOpts = new NatsJSFetchOpts { MaxMsgs = 1, Expires = TimeSpan.FromSeconds(1) };
|
||||
|
||||
NatsJSMsg<byte[]>? dlqMsg = null;
|
||||
await foreach (var msg in consumer.FetchAsync(NatsRawSerializer<byte[]>.Default, fetchOpts))
|
||||
{
|
||||
dlqMsg = msg;
|
||||
await msg.AckAsync(new AckOpts());
|
||||
break;
|
||||
}
|
||||
|
||||
dlqMsg.Should().NotBeNull();
|
||||
}
|
||||
|
||||
private NatsNotifyDeliveryQueue CreateQueue(NotifyDeliveryQueueOptions options)
|
||||
{
|
||||
return new NatsNotifyDeliveryQueue(
|
||||
options,
|
||||
options.Nats,
|
||||
NullLogger<NatsNotifyDeliveryQueue>.Instance,
|
||||
TimeProvider.System);
|
||||
}
|
||||
|
||||
private NotifyDeliveryQueueOptions CreateOptions(Action<NotifyDeliveryQueueOptions>? configure = null)
|
||||
{
|
||||
var url = $"nats://{_nats.Hostname}:{_nats.GetMappedPublicPort(4222)}";
|
||||
|
||||
var opts = new NotifyDeliveryQueueOptions
|
||||
{
|
||||
Transport = NotifyQueueTransportKind.Nats,
|
||||
DefaultLeaseDuration = TimeSpan.FromSeconds(2),
|
||||
MaxDeliveryAttempts = 3,
|
||||
RetryInitialBackoff = TimeSpan.FromMilliseconds(20),
|
||||
RetryMaxBackoff = TimeSpan.FromMilliseconds(200),
|
||||
Nats = new NotifyNatsDeliveryQueueOptions
|
||||
{
|
||||
Url = url,
|
||||
Stream = "NOTIFY_DELIVERY_TEST",
|
||||
Subject = "notify.delivery.test",
|
||||
DeadLetterStream = "NOTIFY_DELIVERY_TEST_DEAD",
|
||||
DeadLetterSubject = "notify.delivery.test.dead",
|
||||
DurableConsumer = "notify-delivery-tests",
|
||||
MaxAckPending = 32,
|
||||
AckWait = TimeSpan.FromSeconds(2),
|
||||
RetryDelay = TimeSpan.FromMilliseconds(100),
|
||||
IdleHeartbeat = TimeSpan.FromMilliseconds(200)
|
||||
}
|
||||
};
|
||||
|
||||
configure?.Invoke(opts);
|
||||
return opts;
|
||||
}
|
||||
|
||||
private bool SkipIfUnavailable()
|
||||
=> _skipReason is not null;
|
||||
|
||||
private static class TestData
|
||||
{
|
||||
public static NotifyDelivery CreateDelivery(string tenantId = "tenant-1")
|
||||
{
|
||||
return NotifyDelivery.Create(
|
||||
deliveryId: Guid.NewGuid().ToString("n"),
|
||||
tenantId: tenantId,
|
||||
ruleId: "rule-1",
|
||||
actionId: "action-1",
|
||||
eventId: Guid.NewGuid(),
|
||||
kind: "scanner.report.ready",
|
||||
status: NotifyDeliveryStatus.Pending,
|
||||
createdAt: DateTimeOffset.UtcNow);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,225 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Text.Json.Nodes;
|
||||
using System.Threading.Tasks;
|
||||
using DotNet.Testcontainers.Builders;
|
||||
using DotNet.Testcontainers.Containers;
|
||||
using DotNet.Testcontainers.Configurations;
|
||||
using FluentAssertions;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using StellaOps.Notify.Models;
|
||||
using StellaOps.Notify.Queue;
|
||||
using StellaOps.Notify.Queue.Nats;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Notify.Queue.Tests;
|
||||
|
||||
public sealed class NatsNotifyEventQueueTests : IAsyncLifetime
|
||||
{
|
||||
private readonly TestcontainersContainer _nats;
|
||||
private string? _skipReason;
|
||||
|
||||
public NatsNotifyEventQueueTests()
|
||||
{
|
||||
_nats = new TestcontainersBuilder<TestcontainersContainer>()
|
||||
.WithImage("nats:2.10-alpine")
|
||||
.WithCleanUp(true)
|
||||
.WithName($"nats-notify-tests-{Guid.NewGuid():N}")
|
||||
.WithPortBinding(4222, true)
|
||||
.WithCommand("--jetstream")
|
||||
.WithWaitStrategy(Wait.ForUnixContainer().UntilPortIsAvailable(4222))
|
||||
.Build();
|
||||
}
|
||||
|
||||
public async Task InitializeAsync()
|
||||
{
|
||||
try
|
||||
{
|
||||
await _nats.StartAsync();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_skipReason = $"NATS-backed tests skipped: {ex.Message}";
|
||||
}
|
||||
}
|
||||
|
||||
public async Task DisposeAsync()
|
||||
{
|
||||
if (_skipReason is not null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
await _nats.DisposeAsync().ConfigureAwait(false);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Publish_ShouldDeduplicate_ByIdempotencyKey()
|
||||
{
|
||||
if (SkipIfUnavailable())
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var options = CreateOptions();
|
||||
await using var queue = CreateQueue(options);
|
||||
|
||||
var notifyEvent = TestData.CreateEvent("tenant-a");
|
||||
var message = new NotifyQueueEventMessage(
|
||||
notifyEvent,
|
||||
options.Nats.Subject,
|
||||
traceId: "trace-1");
|
||||
|
||||
var first = await queue.PublishAsync(message);
|
||||
first.Deduplicated.Should().BeFalse();
|
||||
|
||||
var second = await queue.PublishAsync(message);
|
||||
second.Deduplicated.Should().BeTrue();
|
||||
second.MessageId.Should().Be(first.MessageId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Lease_Acknowledge_ShouldRemoveMessage()
|
||||
{
|
||||
if (SkipIfUnavailable())
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var options = CreateOptions();
|
||||
await using var queue = CreateQueue(options);
|
||||
|
||||
var notifyEvent = TestData.CreateEvent("tenant-b");
|
||||
var message = new NotifyQueueEventMessage(
|
||||
notifyEvent,
|
||||
options.Nats.Subject,
|
||||
traceId: "trace-xyz",
|
||||
attributes: new Dictionary<string, string> { { "source", "scanner" } });
|
||||
|
||||
await queue.PublishAsync(message);
|
||||
|
||||
var leases = await queue.LeaseAsync(new NotifyQueueLeaseRequest("worker-1", 1, TimeSpan.FromSeconds(2)));
|
||||
leases.Should().ContainSingle();
|
||||
|
||||
var lease = leases[0];
|
||||
lease.Attempt.Should().BeGreaterThanOrEqualTo(1);
|
||||
lease.Message.Event.EventId.Should().Be(notifyEvent.EventId);
|
||||
lease.TraceId.Should().Be("trace-xyz");
|
||||
lease.Attributes.Should().ContainKey("source").WhoseValue.Should().Be("scanner");
|
||||
|
||||
await lease.AcknowledgeAsync();
|
||||
|
||||
var afterAck = await queue.LeaseAsync(new NotifyQueueLeaseRequest("worker-1", 1, TimeSpan.FromSeconds(1)));
|
||||
afterAck.Should().BeEmpty();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Lease_ShouldPreserveOrdering()
|
||||
{
|
||||
if (SkipIfUnavailable())
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var options = CreateOptions();
|
||||
await using var queue = CreateQueue(options);
|
||||
|
||||
var first = TestData.CreateEvent();
|
||||
var second = TestData.CreateEvent();
|
||||
|
||||
await queue.PublishAsync(new NotifyQueueEventMessage(first, options.Nats.Subject));
|
||||
await queue.PublishAsync(new NotifyQueueEventMessage(second, options.Nats.Subject));
|
||||
|
||||
var leases = await queue.LeaseAsync(new NotifyQueueLeaseRequest("worker-order", 2, TimeSpan.FromSeconds(2)));
|
||||
leases.Should().HaveCount(2);
|
||||
|
||||
leases.Select(x => x.Message.Event.EventId)
|
||||
.Should()
|
||||
.ContainInOrder(first.EventId, second.EventId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ClaimExpired_ShouldReassignLease()
|
||||
{
|
||||
if (SkipIfUnavailable())
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var options = CreateOptions();
|
||||
await using var queue = CreateQueue(options);
|
||||
|
||||
var notifyEvent = TestData.CreateEvent();
|
||||
await queue.PublishAsync(new NotifyQueueEventMessage(notifyEvent, options.Nats.Subject));
|
||||
|
||||
var leases = await queue.LeaseAsync(new NotifyQueueLeaseRequest("worker-initial", 1, TimeSpan.FromMilliseconds(500)));
|
||||
leases.Should().ContainSingle();
|
||||
|
||||
await Task.Delay(200);
|
||||
|
||||
var claimed = await queue.ClaimExpiredAsync(new NotifyQueueClaimOptions("worker-reclaim", 1, TimeSpan.FromMilliseconds(100)));
|
||||
claimed.Should().ContainSingle();
|
||||
|
||||
var lease = claimed[0];
|
||||
lease.Consumer.Should().Be("worker-reclaim");
|
||||
lease.Message.Event.EventId.Should().Be(notifyEvent.EventId);
|
||||
|
||||
await lease.AcknowledgeAsync();
|
||||
}
|
||||
|
||||
private NatsNotifyEventQueue CreateQueue(NotifyEventQueueOptions options)
|
||||
{
|
||||
return new NatsNotifyEventQueue(
|
||||
options,
|
||||
options.Nats,
|
||||
NullLogger<NatsNotifyEventQueue>.Instance,
|
||||
TimeProvider.System);
|
||||
}
|
||||
|
||||
private NotifyEventQueueOptions CreateOptions()
|
||||
{
|
||||
var connectionUrl = $"nats://{_nats.Hostname}:{_nats.GetMappedPublicPort(4222)}";
|
||||
|
||||
return new NotifyEventQueueOptions
|
||||
{
|
||||
Transport = NotifyQueueTransportKind.Nats,
|
||||
DefaultLeaseDuration = TimeSpan.FromSeconds(2),
|
||||
MaxDeliveryAttempts = 3,
|
||||
RetryInitialBackoff = TimeSpan.FromMilliseconds(50),
|
||||
RetryMaxBackoff = TimeSpan.FromSeconds(1),
|
||||
Nats = new NotifyNatsEventQueueOptions
|
||||
{
|
||||
Url = connectionUrl,
|
||||
Stream = "NOTIFY_TEST",
|
||||
Subject = "notify.test.events",
|
||||
DeadLetterStream = "NOTIFY_TEST_DEAD",
|
||||
DeadLetterSubject = "notify.test.events.dead",
|
||||
DurableConsumer = "notify-test-consumer",
|
||||
MaxAckPending = 32,
|
||||
AckWait = TimeSpan.FromSeconds(2),
|
||||
RetryDelay = TimeSpan.FromMilliseconds(100),
|
||||
IdleHeartbeat = TimeSpan.FromMilliseconds(100)
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
private bool SkipIfUnavailable()
|
||||
=> _skipReason is not null;
|
||||
|
||||
private static class TestData
|
||||
{
|
||||
public static NotifyEvent CreateEvent(string tenant = "tenant-1")
|
||||
{
|
||||
return NotifyEvent.Create(
|
||||
Guid.NewGuid(),
|
||||
kind: "scanner.report.ready",
|
||||
tenant: tenant,
|
||||
ts: DateTimeOffset.UtcNow,
|
||||
payload: new JsonObject
|
||||
{
|
||||
["summary"] = "event"
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,197 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Text.Json.Nodes;
|
||||
using System.Threading.Tasks;
|
||||
using DotNet.Testcontainers.Builders;
|
||||
using DotNet.Testcontainers.Containers;
|
||||
using DotNet.Testcontainers.Configurations;
|
||||
using FluentAssertions;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using StackExchange.Redis;
|
||||
using StellaOps.Notify.Models;
|
||||
using StellaOps.Notify.Queue;
|
||||
using StellaOps.Notify.Queue.Redis;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Notify.Queue.Tests;
|
||||
|
||||
public sealed class RedisNotifyDeliveryQueueTests : IAsyncLifetime
|
||||
{
|
||||
private readonly RedisTestcontainer _redis;
|
||||
private string? _skipReason;
|
||||
|
||||
public RedisNotifyDeliveryQueueTests()
|
||||
{
|
||||
var configuration = new RedisTestcontainerConfiguration();
|
||||
_redis = new TestcontainersBuilder<RedisTestcontainer>()
|
||||
.WithDatabase(configuration)
|
||||
.Build();
|
||||
}
|
||||
|
||||
public async Task InitializeAsync()
|
||||
{
|
||||
try
|
||||
{
|
||||
await _redis.StartAsync();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_skipReason = $"Redis-backed delivery tests skipped: {ex.Message}";
|
||||
}
|
||||
}
|
||||
|
||||
public async Task DisposeAsync()
|
||||
{
|
||||
if (_skipReason is not null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
await _redis.DisposeAsync().AsTask();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Publish_ShouldDeduplicate_ByDeliveryId()
|
||||
{
|
||||
if (SkipIfUnavailable())
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var options = CreateOptions();
|
||||
await using var queue = CreateQueue(options);
|
||||
|
||||
var delivery = TestData.CreateDelivery();
|
||||
var message = new NotifyDeliveryQueueMessage(
|
||||
delivery,
|
||||
channelId: "channel-1",
|
||||
channelType: NotifyChannelType.Slack);
|
||||
|
||||
var first = await queue.PublishAsync(message);
|
||||
first.Deduplicated.Should().BeFalse();
|
||||
|
||||
var second = await queue.PublishAsync(message);
|
||||
second.Deduplicated.Should().BeTrue();
|
||||
second.MessageId.Should().Be(first.MessageId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Release_Retry_ShouldRescheduleDelivery()
|
||||
{
|
||||
if (SkipIfUnavailable())
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var options = CreateOptions();
|
||||
await using var queue = CreateQueue(options);
|
||||
|
||||
await queue.PublishAsync(new NotifyDeliveryQueueMessage(
|
||||
TestData.CreateDelivery(),
|
||||
channelId: "channel-retry",
|
||||
channelType: NotifyChannelType.Teams));
|
||||
|
||||
var lease = (await queue.LeaseAsync(new NotifyQueueLeaseRequest("worker-retry", 1, TimeSpan.FromSeconds(1)))).Single();
|
||||
lease.Attempt.Should().Be(1);
|
||||
|
||||
await lease.ReleaseAsync(NotifyQueueReleaseDisposition.Retry);
|
||||
|
||||
var retried = (await queue.LeaseAsync(new NotifyQueueLeaseRequest("worker-retry", 1, TimeSpan.FromSeconds(1)))).Single();
|
||||
retried.Attempt.Should().Be(2);
|
||||
|
||||
await retried.AcknowledgeAsync();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Release_RetryBeyondMax_ShouldDeadLetter()
|
||||
{
|
||||
if (SkipIfUnavailable())
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var options = CreateOptions(static opts =>
|
||||
{
|
||||
opts.MaxDeliveryAttempts = 2;
|
||||
opts.Redis.DeadLetterStreamName = "notify:deliveries:testdead";
|
||||
});
|
||||
|
||||
await using var queue = CreateQueue(options);
|
||||
|
||||
await queue.PublishAsync(new NotifyDeliveryQueueMessage(
|
||||
TestData.CreateDelivery(),
|
||||
channelId: "channel-dead",
|
||||
channelType: NotifyChannelType.Email));
|
||||
|
||||
var first = (await queue.LeaseAsync(new NotifyQueueLeaseRequest("worker-dead", 1, TimeSpan.FromSeconds(1)))).Single();
|
||||
await first.ReleaseAsync(NotifyQueueReleaseDisposition.Retry);
|
||||
|
||||
var second = (await queue.LeaseAsync(new NotifyQueueLeaseRequest("worker-dead", 1, TimeSpan.FromSeconds(1)))).Single();
|
||||
await second.ReleaseAsync(NotifyQueueReleaseDisposition.Retry);
|
||||
|
||||
await Task.Delay(100);
|
||||
|
||||
var mux = await ConnectionMultiplexer.ConnectAsync(_redis.ConnectionString);
|
||||
var db = mux.GetDatabase();
|
||||
var deadLetters = await db.StreamReadAsync(options.Redis.DeadLetterStreamName, "0-0");
|
||||
deadLetters.Should().NotBeEmpty();
|
||||
}
|
||||
|
||||
private RedisNotifyDeliveryQueue CreateQueue(NotifyDeliveryQueueOptions options)
|
||||
{
|
||||
return new RedisNotifyDeliveryQueue(
|
||||
options,
|
||||
options.Redis,
|
||||
NullLogger<RedisNotifyDeliveryQueue>.Instance,
|
||||
TimeProvider.System,
|
||||
async config => (IConnectionMultiplexer)await ConnectionMultiplexer.ConnectAsync(config).ConfigureAwait(false));
|
||||
}
|
||||
|
||||
private NotifyDeliveryQueueOptions CreateOptions(Action<NotifyDeliveryQueueOptions>? configure = null)
|
||||
{
|
||||
var opts = new NotifyDeliveryQueueOptions
|
||||
{
|
||||
Transport = NotifyQueueTransportKind.Redis,
|
||||
DefaultLeaseDuration = TimeSpan.FromSeconds(1),
|
||||
MaxDeliveryAttempts = 3,
|
||||
RetryInitialBackoff = TimeSpan.FromMilliseconds(10),
|
||||
RetryMaxBackoff = TimeSpan.FromMilliseconds(50),
|
||||
ClaimIdleThreshold = TimeSpan.FromSeconds(1),
|
||||
Redis = new NotifyRedisDeliveryQueueOptions
|
||||
{
|
||||
ConnectionString = _redis.ConnectionString,
|
||||
StreamName = "notify:deliveries:test",
|
||||
ConsumerGroup = "notify-delivery-tests",
|
||||
IdempotencyKeyPrefix = "notify:deliveries:test:idemp:"
|
||||
}
|
||||
};
|
||||
|
||||
configure?.Invoke(opts);
|
||||
return opts;
|
||||
}
|
||||
|
||||
private bool SkipIfUnavailable()
|
||||
=> _skipReason is not null;
|
||||
|
||||
private static class TestData
|
||||
{
|
||||
public static NotifyDelivery CreateDelivery()
|
||||
{
|
||||
var now = DateTimeOffset.UtcNow;
|
||||
return NotifyDelivery.Create(
|
||||
deliveryId: Guid.NewGuid().ToString("n"),
|
||||
tenantId: "tenant-1",
|
||||
ruleId: "rule-1",
|
||||
actionId: "action-1",
|
||||
eventId: Guid.NewGuid(),
|
||||
kind: "scanner.report.ready",
|
||||
status: NotifyDeliveryStatus.Pending,
|
||||
createdAt: now,
|
||||
metadata: new Dictionary<string, string>
|
||||
{
|
||||
["integration"] = "tests"
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,220 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Text.Json.Nodes;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using DotNet.Testcontainers.Builders;
|
||||
using DotNet.Testcontainers.Containers;
|
||||
using DotNet.Testcontainers.Configurations;
|
||||
using FluentAssertions;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using StackExchange.Redis;
|
||||
using StellaOps.Notify.Models;
|
||||
using StellaOps.Notify.Queue;
|
||||
using StellaOps.Notify.Queue.Redis;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Notify.Queue.Tests;
|
||||
|
||||
public sealed class RedisNotifyEventQueueTests : IAsyncLifetime
|
||||
{
|
||||
private readonly RedisTestcontainer _redis;
|
||||
private string? _skipReason;
|
||||
|
||||
public RedisNotifyEventQueueTests()
|
||||
{
|
||||
var configuration = new RedisTestcontainerConfiguration();
|
||||
_redis = new TestcontainersBuilder<RedisTestcontainer>()
|
||||
.WithDatabase(configuration)
|
||||
.Build();
|
||||
}
|
||||
|
||||
public async Task InitializeAsync()
|
||||
{
|
||||
try
|
||||
{
|
||||
await _redis.StartAsync();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_skipReason = $"Redis-backed tests skipped: {ex.Message}";
|
||||
}
|
||||
}
|
||||
|
||||
public async Task DisposeAsync()
|
||||
{
|
||||
if (_skipReason is not null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
await _redis.DisposeAsync().AsTask();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Publish_ShouldDeduplicate_ByIdempotencyKey()
|
||||
{
|
||||
if (SkipIfUnavailable())
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var options = CreateOptions();
|
||||
await using var queue = CreateQueue(options);
|
||||
|
||||
var notifyEvent = TestData.CreateEvent(tenant: "tenant-a");
|
||||
var message = new NotifyQueueEventMessage(notifyEvent, options.Redis.Streams[0].Stream);
|
||||
|
||||
var first = await queue.PublishAsync(message);
|
||||
first.Deduplicated.Should().BeFalse();
|
||||
|
||||
var second = await queue.PublishAsync(message);
|
||||
second.Deduplicated.Should().BeTrue();
|
||||
second.MessageId.Should().Be(first.MessageId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Lease_Acknowledge_ShouldRemoveMessage()
|
||||
{
|
||||
if (SkipIfUnavailable())
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var options = CreateOptions();
|
||||
await using var queue = CreateQueue(options);
|
||||
|
||||
var notifyEvent = TestData.CreateEvent(tenant: "tenant-b");
|
||||
var message = new NotifyQueueEventMessage(
|
||||
notifyEvent,
|
||||
options.Redis.Streams[0].Stream,
|
||||
traceId: "trace-123",
|
||||
attributes: new Dictionary<string, string> { { "source", "scanner" } });
|
||||
|
||||
await queue.PublishAsync(message);
|
||||
|
||||
var leases = await queue.LeaseAsync(new NotifyQueueLeaseRequest("worker-1", 1, TimeSpan.FromSeconds(5)));
|
||||
leases.Should().ContainSingle();
|
||||
|
||||
var lease = leases[0];
|
||||
lease.Attempt.Should().Be(1);
|
||||
lease.Message.Event.EventId.Should().Be(notifyEvent.EventId);
|
||||
lease.TraceId.Should().Be("trace-123");
|
||||
lease.Attributes.Should().ContainKey("source").WhoseValue.Should().Be("scanner");
|
||||
|
||||
await lease.AcknowledgeAsync();
|
||||
|
||||
var afterAck = await queue.LeaseAsync(new NotifyQueueLeaseRequest("worker-1", 1, TimeSpan.FromSeconds(5)));
|
||||
afterAck.Should().BeEmpty();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Lease_ShouldPreserveOrdering()
|
||||
{
|
||||
if (SkipIfUnavailable())
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var options = CreateOptions();
|
||||
await using var queue = CreateQueue(options);
|
||||
|
||||
var stream = options.Redis.Streams[0].Stream;
|
||||
var firstEvent = TestData.CreateEvent();
|
||||
var secondEvent = TestData.CreateEvent();
|
||||
|
||||
await queue.PublishAsync(new NotifyQueueEventMessage(firstEvent, stream));
|
||||
await queue.PublishAsync(new NotifyQueueEventMessage(secondEvent, stream));
|
||||
|
||||
var leases = await queue.LeaseAsync(new NotifyQueueLeaseRequest("worker-order", 2, TimeSpan.FromSeconds(5)));
|
||||
leases.Should().HaveCount(2);
|
||||
|
||||
leases.Select(l => l.Message.Event.EventId)
|
||||
.Should()
|
||||
.ContainInOrder(new[] { firstEvent.EventId, secondEvent.EventId });
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ClaimExpired_ShouldReassignLease()
|
||||
{
|
||||
if (SkipIfUnavailable())
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var options = CreateOptions();
|
||||
await using var queue = CreateQueue(options);
|
||||
|
||||
var notifyEvent = TestData.CreateEvent();
|
||||
await queue.PublishAsync(new NotifyQueueEventMessage(notifyEvent, options.Redis.Streams[0].Stream));
|
||||
|
||||
var leases = await queue.LeaseAsync(new NotifyQueueLeaseRequest("worker-initial", 1, TimeSpan.FromSeconds(1)));
|
||||
leases.Should().ContainSingle();
|
||||
|
||||
// Ensure the message has been pending long enough for claim.
|
||||
await Task.Delay(50);
|
||||
|
||||
var claimed = await queue.ClaimExpiredAsync(new NotifyQueueClaimOptions("worker-reclaim", 1, TimeSpan.Zero));
|
||||
claimed.Should().ContainSingle();
|
||||
|
||||
var lease = claimed[0];
|
||||
lease.Consumer.Should().Be("worker-reclaim");
|
||||
lease.Message.Event.EventId.Should().Be(notifyEvent.EventId);
|
||||
|
||||
await lease.AcknowledgeAsync();
|
||||
}
|
||||
|
||||
private RedisNotifyEventQueue CreateQueue(NotifyEventQueueOptions options)
|
||||
{
|
||||
return new RedisNotifyEventQueue(
|
||||
options,
|
||||
options.Redis,
|
||||
NullLogger<RedisNotifyEventQueue>.Instance,
|
||||
TimeProvider.System,
|
||||
async config => (IConnectionMultiplexer)await ConnectionMultiplexer.ConnectAsync(config).ConfigureAwait(false));
|
||||
}
|
||||
|
||||
private NotifyEventQueueOptions CreateOptions()
|
||||
{
|
||||
var streamOptions = new NotifyRedisEventStreamOptions
|
||||
{
|
||||
Stream = "notify:test:events",
|
||||
ConsumerGroup = "notify-test-consumers",
|
||||
IdempotencyKeyPrefix = "notify:test:idemp:",
|
||||
ApproximateMaxLength = 1024
|
||||
};
|
||||
|
||||
var redisOptions = new NotifyRedisEventQueueOptions
|
||||
{
|
||||
ConnectionString = _redis.ConnectionString,
|
||||
Streams = new List<NotifyRedisEventStreamOptions> { streamOptions }
|
||||
};
|
||||
|
||||
return new NotifyEventQueueOptions
|
||||
{
|
||||
Transport = NotifyQueueTransportKind.Redis,
|
||||
DefaultLeaseDuration = TimeSpan.FromSeconds(5),
|
||||
Redis = redisOptions
|
||||
};
|
||||
}
|
||||
|
||||
private bool SkipIfUnavailable()
|
||||
=> _skipReason is not null;
|
||||
|
||||
private static class TestData
|
||||
{
|
||||
public static NotifyEvent CreateEvent(string tenant = "tenant-1")
|
||||
{
|
||||
return NotifyEvent.Create(
|
||||
Guid.NewGuid(),
|
||||
kind: "scanner.report.ready",
|
||||
tenant: tenant,
|
||||
ts: DateTimeOffset.UtcNow,
|
||||
payload: new JsonObject
|
||||
{
|
||||
["summary"] = "event"
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
<?xml version='1.0' encoding='utf-8'?>
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
<IsPackable>false</IsPackable>
|
||||
<UseConcelierTestInfra>false</UseConcelierTestInfra>
|
||||
</PropertyGroup>
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" Version="10.0.0-rc.2.25502.107" />
|
||||
<PackageReference Include="DotNet.Testcontainers" Version="1.7.0-beta.2269" />
|
||||
<PackageReference Include="FluentAssertions" Version="6.12.0" />
|
||||
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.14.0" />
|
||||
<PackageReference Include="xunit" Version="2.9.2" />
|
||||
<PackageReference Include="xunit.runner.visualstudio" Version="2.8.2">
|
||||
<PrivateAssets>all</PrivateAssets>
|
||||
</PackageReference>
|
||||
<PackageReference Include="coverlet.collector" Version="6.0.4">
|
||||
<PrivateAssets>all</PrivateAssets>
|
||||
</PackageReference>
|
||||
</ItemGroup>
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="../../__Libraries/StellaOps.Notify.Models/StellaOps.Notify.Models.csproj" />
|
||||
<ProjectReference Include="../../__Libraries/StellaOps.Notify.Queue/StellaOps.Notify.Queue.csproj" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
@@ -0,0 +1,3 @@
|
||||
using Xunit;
|
||||
|
||||
[assembly: CollectionBehavior(DisableTestParallelization = true)]
|
||||
@@ -0,0 +1 @@
|
||||
global using Xunit;
|
||||
@@ -0,0 +1,92 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Microsoft.Extensions.Options;
|
||||
using Mongo2Go;
|
||||
using MongoDB.Bson;
|
||||
using MongoDB.Driver;
|
||||
using StellaOps.Notify.Storage.Mongo.Internal;
|
||||
using StellaOps.Notify.Storage.Mongo.Migrations;
|
||||
using StellaOps.Notify.Storage.Mongo.Options;
|
||||
|
||||
namespace StellaOps.Notify.Storage.Mongo.Tests.Internal;
|
||||
|
||||
public sealed class NotifyMongoMigrationTests : IAsyncLifetime
|
||||
{
|
||||
private readonly MongoDbRunner _runner = MongoDbRunner.Start(singleNodeReplSet: true);
|
||||
private readonly NotifyMongoContext _context;
|
||||
private readonly NotifyMongoInitializer _initializer;
|
||||
|
||||
public NotifyMongoMigrationTests()
|
||||
{
|
||||
var options = Microsoft.Extensions.Options.Options.Create(new NotifyMongoOptions
|
||||
{
|
||||
ConnectionString = _runner.ConnectionString,
|
||||
Database = "notify-migration-tests",
|
||||
DeliveryHistoryRetention = TimeSpan.FromDays(45),
|
||||
MigrationsCollection = "notify_migrations_tests"
|
||||
});
|
||||
|
||||
_context = new NotifyMongoContext(options, NullLogger<NotifyMongoContext>.Instance);
|
||||
_initializer = CreateInitializer(_context);
|
||||
}
|
||||
|
||||
public async Task InitializeAsync()
|
||||
{
|
||||
await _initializer.EnsureIndexesAsync();
|
||||
}
|
||||
|
||||
public Task DisposeAsync()
|
||||
{
|
||||
_runner.Dispose();
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task EnsureIndexesCreatesExpectedDefinitions()
|
||||
{
|
||||
// run twice to ensure idempotency
|
||||
await _initializer.EnsureIndexesAsync();
|
||||
|
||||
var deliveriesIndexes = await GetIndexesAsync(_context.Options.DeliveriesCollection);
|
||||
Assert.Contains("tenant_sortKey", deliveriesIndexes.Select(doc => doc["name"].AsString));
|
||||
Assert.Contains("tenant_status", deliveriesIndexes.Select(doc => doc["name"].AsString));
|
||||
var ttlIndex = deliveriesIndexes.Single(doc => doc["name"].AsString == "completedAt_ttl");
|
||||
Assert.Equal(_context.Options.DeliveryHistoryRetention.TotalSeconds, ttlIndex["expireAfterSeconds"].ToDouble());
|
||||
|
||||
var locksIndexes = await GetIndexesAsync(_context.Options.LocksCollection);
|
||||
Assert.Contains("tenant_resource", locksIndexes.Select(doc => doc["name"].AsString));
|
||||
Assert.True(locksIndexes.Single(doc => doc["name"].AsString == "tenant_resource")["unique"].ToBoolean());
|
||||
Assert.Contains("expiresAt_ttl", locksIndexes.Select(doc => doc["name"].AsString));
|
||||
|
||||
var digestsIndexes = await GetIndexesAsync(_context.Options.DigestsCollection);
|
||||
Assert.Contains("tenant_actionKey", digestsIndexes.Select(doc => doc["name"].AsString));
|
||||
|
||||
var rulesIndexes = await GetIndexesAsync(_context.Options.RulesCollection);
|
||||
Assert.Contains("tenant_enabled", rulesIndexes.Select(doc => doc["name"].AsString));
|
||||
|
||||
var migrationsIndexes = await GetIndexesAsync(_context.Options.MigrationsCollection);
|
||||
Assert.Contains("migrationId_unique", migrationsIndexes.Select(doc => doc["name"].AsString));
|
||||
}
|
||||
|
||||
private async Task<IReadOnlyList<BsonDocument>> GetIndexesAsync(string collectionName)
|
||||
{
|
||||
var collection = _context.Database.GetCollection<BsonDocument>(collectionName);
|
||||
var cursor = await collection.Indexes.ListAsync().ConfigureAwait(false);
|
||||
return await cursor.ToListAsync().ConfigureAwait(false);
|
||||
}
|
||||
|
||||
private static NotifyMongoInitializer CreateInitializer(NotifyMongoContext context)
|
||||
{
|
||||
var migrations = new INotifyMongoMigration[]
|
||||
{
|
||||
new EnsureNotifyCollectionsMigration(NullLogger<EnsureNotifyCollectionsMigration>.Instance),
|
||||
new EnsureNotifyIndexesMigration()
|
||||
};
|
||||
|
||||
var runner = new NotifyMongoMigrationRunner(context, migrations, NullLogger<NotifyMongoMigrationRunner>.Instance);
|
||||
return new NotifyMongoInitializer(context, runner, NullLogger<NotifyMongoInitializer>.Instance);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Microsoft.Extensions.Options;
|
||||
using Mongo2Go;
|
||||
using MongoDB.Bson;
|
||||
using StellaOps.Notify.Storage.Mongo.Documents;
|
||||
using StellaOps.Notify.Storage.Mongo.Internal;
|
||||
using StellaOps.Notify.Storage.Mongo.Migrations;
|
||||
using StellaOps.Notify.Storage.Mongo.Options;
|
||||
using StellaOps.Notify.Storage.Mongo.Repositories;
|
||||
|
||||
namespace StellaOps.Notify.Storage.Mongo.Tests.Repositories;
|
||||
|
||||
public sealed class NotifyAuditRepositoryTests : IAsyncLifetime
|
||||
{
|
||||
private readonly MongoDbRunner _runner = MongoDbRunner.Start(singleNodeReplSet: true);
|
||||
private readonly NotifyMongoContext _context;
|
||||
private readonly NotifyMongoInitializer _initializer;
|
||||
private readonly NotifyAuditRepository _repository;
|
||||
|
||||
public NotifyAuditRepositoryTests()
|
||||
{
|
||||
var options = Microsoft.Extensions.Options.Options.Create(new NotifyMongoOptions
|
||||
{
|
||||
ConnectionString = _runner.ConnectionString,
|
||||
Database = "notify-audit-tests"
|
||||
});
|
||||
|
||||
_context = new NotifyMongoContext(options, NullLogger<NotifyMongoContext>.Instance);
|
||||
_initializer = CreateInitializer(_context);
|
||||
_repository = new NotifyAuditRepository(_context);
|
||||
}
|
||||
|
||||
public async Task InitializeAsync()
|
||||
{
|
||||
await _initializer.EnsureIndexesAsync();
|
||||
}
|
||||
|
||||
public Task DisposeAsync()
|
||||
{
|
||||
_runner.Dispose();
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task AppendAndQuery()
|
||||
{
|
||||
var entry = new NotifyAuditEntryDocument
|
||||
{
|
||||
TenantId = "tenant-a",
|
||||
Actor = "user@example.com",
|
||||
Action = "create-rule",
|
||||
EntityId = "rule-1",
|
||||
EntityType = "rule",
|
||||
Timestamp = DateTimeOffset.UtcNow,
|
||||
Payload = new BsonDocument("ruleId", "rule-1")
|
||||
};
|
||||
|
||||
await _repository.AppendAsync(entry);
|
||||
var list = await _repository.QueryAsync("tenant-a", DateTimeOffset.UtcNow.AddMinutes(-5), 10);
|
||||
Assert.Single(list);
|
||||
Assert.Equal("create-rule", list[0].Action);
|
||||
}
|
||||
|
||||
private static NotifyMongoInitializer CreateInitializer(NotifyMongoContext context)
|
||||
{
|
||||
var migrations = new INotifyMongoMigration[]
|
||||
{
|
||||
new EnsureNotifyCollectionsMigration(NullLogger<EnsureNotifyCollectionsMigration>.Instance),
|
||||
new EnsureNotifyIndexesMigration()
|
||||
};
|
||||
|
||||
var runner = new NotifyMongoMigrationRunner(context, migrations, NullLogger<NotifyMongoMigrationRunner>.Instance);
|
||||
return new NotifyMongoInitializer(context, runner, NullLogger<NotifyMongoInitializer>.Instance);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Microsoft.Extensions.Options;
|
||||
using Mongo2Go;
|
||||
using StellaOps.Notify.Models;
|
||||
using StellaOps.Notify.Storage.Mongo.Internal;
|
||||
using StellaOps.Notify.Storage.Mongo.Migrations;
|
||||
using StellaOps.Notify.Storage.Mongo.Options;
|
||||
using StellaOps.Notify.Storage.Mongo.Repositories;
|
||||
|
||||
namespace StellaOps.Notify.Storage.Mongo.Tests.Repositories;
|
||||
|
||||
public sealed class NotifyChannelRepositoryTests : IAsyncLifetime
|
||||
{
|
||||
private readonly MongoDbRunner _runner = MongoDbRunner.Start(singleNodeReplSet: true);
|
||||
private readonly NotifyMongoContext _context;
|
||||
private readonly NotifyMongoInitializer _initializer;
|
||||
private readonly NotifyChannelRepository _repository;
|
||||
|
||||
public NotifyChannelRepositoryTests()
|
||||
{
|
||||
var options = Microsoft.Extensions.Options.Options.Create(new NotifyMongoOptions
|
||||
{
|
||||
ConnectionString = _runner.ConnectionString,
|
||||
Database = "notify-channel-tests"
|
||||
});
|
||||
|
||||
_context = new NotifyMongoContext(options, NullLogger<NotifyMongoContext>.Instance);
|
||||
_initializer = CreateInitializer(_context);
|
||||
_repository = new NotifyChannelRepository(_context);
|
||||
}
|
||||
|
||||
public Task DisposeAsync()
|
||||
{
|
||||
_runner.Dispose();
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
public async Task InitializeAsync()
|
||||
{
|
||||
await _initializer.EnsureIndexesAsync();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task UpsertChannelPersistsData()
|
||||
{
|
||||
var channel = NotifyChannel.Create(
|
||||
channelId: "channel-1",
|
||||
tenantId: "tenant-a",
|
||||
name: "slack:sec",
|
||||
type: NotifyChannelType.Slack,
|
||||
config: NotifyChannelConfig.Create(secretRef: "ref://secret"));
|
||||
|
||||
await _repository.UpsertAsync(channel);
|
||||
|
||||
var fetched = await _repository.GetAsync("tenant-a", "channel-1");
|
||||
Assert.NotNull(fetched);
|
||||
Assert.Equal(channel.ChannelId, fetched!.ChannelId);
|
||||
|
||||
var listed = await _repository.ListAsync("tenant-a");
|
||||
Assert.Single(listed);
|
||||
|
||||
await _repository.DeleteAsync("tenant-a", "channel-1");
|
||||
Assert.Null(await _repository.GetAsync("tenant-a", "channel-1"));
|
||||
}
|
||||
|
||||
private static NotifyMongoInitializer CreateInitializer(NotifyMongoContext context)
|
||||
{
|
||||
var migrations = new INotifyMongoMigration[]
|
||||
{
|
||||
new EnsureNotifyCollectionsMigration(NullLogger<EnsureNotifyCollectionsMigration>.Instance),
|
||||
new EnsureNotifyIndexesMigration()
|
||||
};
|
||||
|
||||
var runner = new NotifyMongoMigrationRunner(context, migrations, NullLogger<NotifyMongoMigrationRunner>.Instance);
|
||||
return new NotifyMongoInitializer(context, runner, NullLogger<NotifyMongoInitializer>.Instance);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,119 @@
|
||||
using System;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Microsoft.Extensions.Options;
|
||||
using Mongo2Go;
|
||||
using StellaOps.Notify.Models;
|
||||
using StellaOps.Notify.Storage.Mongo.Internal;
|
||||
using StellaOps.Notify.Storage.Mongo.Migrations;
|
||||
using StellaOps.Notify.Storage.Mongo.Options;
|
||||
using StellaOps.Notify.Storage.Mongo.Repositories;
|
||||
|
||||
namespace StellaOps.Notify.Storage.Mongo.Tests.Repositories;
|
||||
|
||||
public sealed class NotifyDeliveryRepositoryTests : IAsyncLifetime
|
||||
{
|
||||
private readonly MongoDbRunner _runner = MongoDbRunner.Start(singleNodeReplSet: true);
|
||||
private readonly NotifyMongoContext _context;
|
||||
private readonly NotifyMongoInitializer _initializer;
|
||||
private readonly NotifyDeliveryRepository _repository;
|
||||
|
||||
public NotifyDeliveryRepositoryTests()
|
||||
{
|
||||
var options = Microsoft.Extensions.Options.Options.Create(new NotifyMongoOptions
|
||||
{
|
||||
ConnectionString = _runner.ConnectionString,
|
||||
Database = "notify-delivery-tests"
|
||||
});
|
||||
|
||||
_context = new NotifyMongoContext(options, NullLogger<NotifyMongoContext>.Instance);
|
||||
_initializer = CreateInitializer(_context);
|
||||
_repository = new NotifyDeliveryRepository(_context);
|
||||
}
|
||||
|
||||
public async Task InitializeAsync()
|
||||
{
|
||||
await _initializer.EnsureIndexesAsync();
|
||||
}
|
||||
|
||||
public Task DisposeAsync()
|
||||
{
|
||||
_runner.Dispose();
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task AppendAndQueryWithPaging()
|
||||
{
|
||||
var now = DateTimeOffset.UtcNow;
|
||||
var deliveries = new[]
|
||||
{
|
||||
NotifyDelivery.Create(
|
||||
deliveryId: "delivery-1",
|
||||
tenantId: "tenant-a",
|
||||
ruleId: "rule-1",
|
||||
actionId: "action-1",
|
||||
eventId: Guid.NewGuid(),
|
||||
kind: NotifyEventKinds.ScannerReportReady,
|
||||
status: NotifyDeliveryStatus.Sent,
|
||||
createdAt: now.AddMinutes(-2),
|
||||
sentAt: now.AddMinutes(-2)),
|
||||
NotifyDelivery.Create(
|
||||
deliveryId: "delivery-2",
|
||||
tenantId: "tenant-a",
|
||||
ruleId: "rule-2",
|
||||
actionId: "action-2",
|
||||
eventId: Guid.NewGuid(),
|
||||
kind: NotifyEventKinds.ScannerReportReady,
|
||||
status: NotifyDeliveryStatus.Failed,
|
||||
createdAt: now.AddMinutes(-1),
|
||||
completedAt: now.AddMinutes(-1)),
|
||||
NotifyDelivery.Create(
|
||||
deliveryId: "delivery-3",
|
||||
tenantId: "tenant-a",
|
||||
ruleId: "rule-3",
|
||||
actionId: "action-3",
|
||||
eventId: Guid.NewGuid(),
|
||||
kind: NotifyEventKinds.ScannerReportReady,
|
||||
status: NotifyDeliveryStatus.Sent,
|
||||
createdAt: now,
|
||||
sentAt: now)
|
||||
};
|
||||
|
||||
foreach (var delivery in deliveries)
|
||||
{
|
||||
await _repository.AppendAsync(delivery);
|
||||
}
|
||||
|
||||
var fetched = await _repository.GetAsync("tenant-a", "delivery-3");
|
||||
Assert.NotNull(fetched);
|
||||
Assert.Equal("delivery-3", fetched!.DeliveryId);
|
||||
|
||||
var page1 = await _repository.QueryAsync("tenant-a", now.AddHours(-1), "sent", 1);
|
||||
Assert.Single(page1.Items);
|
||||
Assert.Equal("delivery-3", page1.Items[0].DeliveryId);
|
||||
Assert.False(string.IsNullOrWhiteSpace(page1.ContinuationToken));
|
||||
|
||||
var page2 = await _repository.QueryAsync("tenant-a", now.AddHours(-1), "sent", 1, page1.ContinuationToken);
|
||||
Assert.Single(page2.Items);
|
||||
Assert.Equal("delivery-1", page2.Items[0].DeliveryId);
|
||||
Assert.Null(page2.ContinuationToken);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task QueryAsyncWithInvalidContinuationThrows()
|
||||
{
|
||||
await Assert.ThrowsAsync<ArgumentException>(() => _repository.QueryAsync("tenant-a", null, null, 10, "not-a-token"));
|
||||
}
|
||||
|
||||
private static NotifyMongoInitializer CreateInitializer(NotifyMongoContext context)
|
||||
{
|
||||
var migrations = new INotifyMongoMigration[]
|
||||
{
|
||||
new EnsureNotifyCollectionsMigration(NullLogger<EnsureNotifyCollectionsMigration>.Instance),
|
||||
new EnsureNotifyIndexesMigration()
|
||||
};
|
||||
|
||||
var runner = new NotifyMongoMigrationRunner(context, migrations, NullLogger<NotifyMongoMigrationRunner>.Instance);
|
||||
return new NotifyMongoInitializer(context, runner, NullLogger<NotifyMongoInitializer>.Instance);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Microsoft.Extensions.Options;
|
||||
using Mongo2Go;
|
||||
using StellaOps.Notify.Storage.Mongo.Documents;
|
||||
using StellaOps.Notify.Storage.Mongo.Internal;
|
||||
using StellaOps.Notify.Storage.Mongo.Migrations;
|
||||
using StellaOps.Notify.Storage.Mongo.Options;
|
||||
using StellaOps.Notify.Storage.Mongo.Repositories;
|
||||
|
||||
namespace StellaOps.Notify.Storage.Mongo.Tests.Repositories;
|
||||
|
||||
public sealed class NotifyDigestRepositoryTests : IAsyncLifetime
|
||||
{
|
||||
private readonly MongoDbRunner _runner = MongoDbRunner.Start(singleNodeReplSet: true);
|
||||
private readonly NotifyMongoContext _context;
|
||||
private readonly NotifyMongoInitializer _initializer;
|
||||
private readonly NotifyDigestRepository _repository;
|
||||
|
||||
public NotifyDigestRepositoryTests()
|
||||
{
|
||||
var options = Microsoft.Extensions.Options.Options.Create(new NotifyMongoOptions
|
||||
{
|
||||
ConnectionString = _runner.ConnectionString,
|
||||
Database = "notify-digest-tests"
|
||||
});
|
||||
|
||||
_context = new NotifyMongoContext(options, NullLogger<NotifyMongoContext>.Instance);
|
||||
_initializer = CreateInitializer(_context);
|
||||
_repository = new NotifyDigestRepository(_context);
|
||||
}
|
||||
|
||||
public async Task InitializeAsync()
|
||||
{
|
||||
await _initializer.EnsureIndexesAsync();
|
||||
}
|
||||
|
||||
public Task DisposeAsync()
|
||||
{
|
||||
_runner.Dispose();
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task UpsertAndRemove()
|
||||
{
|
||||
var digest = new NotifyDigestDocument
|
||||
{
|
||||
TenantId = "tenant-a",
|
||||
ActionKey = "action-1",
|
||||
Window = "hourly",
|
||||
OpenedAt = DateTimeOffset.UtcNow,
|
||||
Status = "open",
|
||||
Items = new List<NotifyDigestItemDocument>
|
||||
{
|
||||
new() { EventId = Guid.NewGuid().ToString() }
|
||||
}
|
||||
};
|
||||
|
||||
await _repository.UpsertAsync(digest);
|
||||
var fetched = await _repository.GetAsync("tenant-a", "action-1");
|
||||
Assert.NotNull(fetched);
|
||||
Assert.Equal("action-1", fetched!.ActionKey);
|
||||
|
||||
await _repository.RemoveAsync("tenant-a", "action-1");
|
||||
Assert.Null(await _repository.GetAsync("tenant-a", "action-1"));
|
||||
}
|
||||
|
||||
private static NotifyMongoInitializer CreateInitializer(NotifyMongoContext context)
|
||||
{
|
||||
var migrations = new INotifyMongoMigration[]
|
||||
{
|
||||
new EnsureNotifyCollectionsMigration(NullLogger<EnsureNotifyCollectionsMigration>.Instance),
|
||||
new EnsureNotifyIndexesMigration()
|
||||
};
|
||||
|
||||
var runner = new NotifyMongoMigrationRunner(context, migrations, NullLogger<NotifyMongoMigrationRunner>.Instance);
|
||||
return new NotifyMongoInitializer(context, runner, NullLogger<NotifyMongoInitializer>.Instance);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Microsoft.Extensions.Options;
|
||||
using Mongo2Go;
|
||||
using StellaOps.Notify.Storage.Mongo.Internal;
|
||||
using StellaOps.Notify.Storage.Mongo.Migrations;
|
||||
using StellaOps.Notify.Storage.Mongo.Options;
|
||||
using StellaOps.Notify.Storage.Mongo.Repositories;
|
||||
|
||||
namespace StellaOps.Notify.Storage.Mongo.Tests.Repositories;
|
||||
|
||||
public sealed class NotifyLockRepositoryTests : IAsyncLifetime
|
||||
{
|
||||
private readonly MongoDbRunner _runner = MongoDbRunner.Start(singleNodeReplSet: true);
|
||||
private readonly NotifyMongoContext _context;
|
||||
private readonly NotifyMongoInitializer _initializer;
|
||||
private readonly NotifyLockRepository _repository;
|
||||
|
||||
public NotifyLockRepositoryTests()
|
||||
{
|
||||
var options = Microsoft.Extensions.Options.Options.Create(new NotifyMongoOptions
|
||||
{
|
||||
ConnectionString = _runner.ConnectionString,
|
||||
Database = "notify-lock-tests"
|
||||
});
|
||||
|
||||
_context = new NotifyMongoContext(options, NullLogger<NotifyMongoContext>.Instance);
|
||||
_initializer = CreateInitializer(_context);
|
||||
_repository = new NotifyLockRepository(_context);
|
||||
}
|
||||
|
||||
public async Task InitializeAsync()
|
||||
{
|
||||
await _initializer.EnsureIndexesAsync();
|
||||
}
|
||||
|
||||
public Task DisposeAsync()
|
||||
{
|
||||
_runner.Dispose();
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task AcquireAndRelease()
|
||||
{
|
||||
var acquired = await _repository.TryAcquireAsync("tenant-a", "resource-1", "owner-1", TimeSpan.FromMinutes(1));
|
||||
Assert.True(acquired);
|
||||
|
||||
var second = await _repository.TryAcquireAsync("tenant-a", "resource-1", "owner-2", TimeSpan.FromMinutes(1));
|
||||
Assert.False(second);
|
||||
|
||||
await _repository.ReleaseAsync("tenant-a", "resource-1", "owner-1");
|
||||
var third = await _repository.TryAcquireAsync("tenant-a", "resource-1", "owner-2", TimeSpan.FromMinutes(1));
|
||||
Assert.True(third);
|
||||
}
|
||||
|
||||
private static NotifyMongoInitializer CreateInitializer(NotifyMongoContext context)
|
||||
{
|
||||
var migrations = new INotifyMongoMigration[]
|
||||
{
|
||||
new EnsureNotifyCollectionsMigration(NullLogger<EnsureNotifyCollectionsMigration>.Instance),
|
||||
new EnsureNotifyIndexesMigration()
|
||||
};
|
||||
|
||||
var runner = new NotifyMongoMigrationRunner(context, migrations, NullLogger<NotifyMongoMigrationRunner>.Instance);
|
||||
return new NotifyMongoInitializer(context, runner, NullLogger<NotifyMongoInitializer>.Instance);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Microsoft.Extensions.Options;
|
||||
using Mongo2Go;
|
||||
using StellaOps.Notify.Models;
|
||||
using StellaOps.Notify.Storage.Mongo.Internal;
|
||||
using StellaOps.Notify.Storage.Mongo.Migrations;
|
||||
using StellaOps.Notify.Storage.Mongo.Options;
|
||||
using StellaOps.Notify.Storage.Mongo.Repositories;
|
||||
|
||||
namespace StellaOps.Notify.Storage.Mongo.Tests.Repositories;
|
||||
|
||||
public sealed class NotifyRuleRepositoryTests : IAsyncLifetime
|
||||
{
|
||||
private readonly MongoDbRunner _runner = MongoDbRunner.Start(singleNodeReplSet: true);
|
||||
private readonly NotifyMongoContext _context;
|
||||
private readonly NotifyMongoInitializer _initializer;
|
||||
private readonly NotifyRuleRepository _repository;
|
||||
|
||||
public NotifyRuleRepositoryTests()
|
||||
{
|
||||
var options = Microsoft.Extensions.Options.Options.Create(new NotifyMongoOptions
|
||||
{
|
||||
ConnectionString = _runner.ConnectionString,
|
||||
Database = "notify-rule-tests"
|
||||
});
|
||||
|
||||
_context = new NotifyMongoContext(options, NullLogger<NotifyMongoContext>.Instance);
|
||||
_initializer = CreateInitializer(_context);
|
||||
_repository = new NotifyRuleRepository(_context);
|
||||
}
|
||||
|
||||
public Task DisposeAsync()
|
||||
{
|
||||
_runner.Dispose();
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
public async Task InitializeAsync()
|
||||
{
|
||||
await _initializer.EnsureIndexesAsync();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task UpsertRoundtripsData()
|
||||
{
|
||||
var rule = NotifyRule.Create(
|
||||
ruleId: "rule-1",
|
||||
tenantId: "tenant-a",
|
||||
name: "Critical Alerts",
|
||||
match: NotifyRuleMatch.Create(eventKinds: new[] { NotifyEventKinds.ScannerReportReady }),
|
||||
actions: new[] { new NotifyRuleAction("action-1", "slack:sec") });
|
||||
|
||||
await _repository.UpsertAsync(rule);
|
||||
|
||||
var fetched = await _repository.GetAsync("tenant-a", "rule-1");
|
||||
Assert.NotNull(fetched);
|
||||
Assert.Equal(rule.RuleId, fetched!.RuleId);
|
||||
Assert.Equal(rule.SchemaVersion, fetched.SchemaVersion);
|
||||
|
||||
var listed = await _repository.ListAsync("tenant-a");
|
||||
Assert.Single(listed);
|
||||
|
||||
await _repository.DeleteAsync("tenant-a", "rule-1");
|
||||
var deleted = await _repository.GetAsync("tenant-a", "rule-1");
|
||||
Assert.Null(deleted);
|
||||
}
|
||||
|
||||
private static NotifyMongoInitializer CreateInitializer(NotifyMongoContext context)
|
||||
{
|
||||
var migrations = new INotifyMongoMigration[]
|
||||
{
|
||||
new EnsureNotifyCollectionsMigration(NullLogger<EnsureNotifyCollectionsMigration>.Instance),
|
||||
new EnsureNotifyIndexesMigration()
|
||||
};
|
||||
|
||||
var runner = new NotifyMongoMigrationRunner(context, migrations, NullLogger<NotifyMongoMigrationRunner>.Instance);
|
||||
return new NotifyMongoInitializer(context, runner, NullLogger<NotifyMongoInitializer>.Instance);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Microsoft.Extensions.Options;
|
||||
using Mongo2Go;
|
||||
using StellaOps.Notify.Models;
|
||||
using StellaOps.Notify.Storage.Mongo.Internal;
|
||||
using StellaOps.Notify.Storage.Mongo.Migrations;
|
||||
using StellaOps.Notify.Storage.Mongo.Options;
|
||||
using StellaOps.Notify.Storage.Mongo.Repositories;
|
||||
|
||||
namespace StellaOps.Notify.Storage.Mongo.Tests.Repositories;
|
||||
|
||||
public sealed class NotifyTemplateRepositoryTests : IAsyncLifetime
|
||||
{
|
||||
private readonly MongoDbRunner _runner = MongoDbRunner.Start(singleNodeReplSet: true);
|
||||
private readonly NotifyMongoContext _context;
|
||||
private readonly NotifyMongoInitializer _initializer;
|
||||
private readonly NotifyTemplateRepository _repository;
|
||||
|
||||
public NotifyTemplateRepositoryTests()
|
||||
{
|
||||
var options = Microsoft.Extensions.Options.Options.Create(new NotifyMongoOptions
|
||||
{
|
||||
ConnectionString = _runner.ConnectionString,
|
||||
Database = "notify-template-tests"
|
||||
});
|
||||
|
||||
_context = new NotifyMongoContext(options, NullLogger<NotifyMongoContext>.Instance);
|
||||
_initializer = CreateInitializer(_context);
|
||||
_repository = new NotifyTemplateRepository(_context);
|
||||
}
|
||||
|
||||
public Task DisposeAsync()
|
||||
{
|
||||
_runner.Dispose();
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
public async Task InitializeAsync()
|
||||
{
|
||||
await _initializer.EnsureIndexesAsync();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task UpsertTemplatePersistsData()
|
||||
{
|
||||
var template = NotifyTemplate.Create(
|
||||
templateId: "template-1",
|
||||
tenantId: "tenant-a",
|
||||
channelType: NotifyChannelType.Slack,
|
||||
key: "concise",
|
||||
locale: "en-us",
|
||||
body: "{{summary}}",
|
||||
renderMode: NotifyTemplateRenderMode.Markdown,
|
||||
format: NotifyDeliveryFormat.Slack);
|
||||
|
||||
await _repository.UpsertAsync(template);
|
||||
|
||||
var fetched = await _repository.GetAsync("tenant-a", "template-1");
|
||||
Assert.NotNull(fetched);
|
||||
Assert.Equal(template.TemplateId, fetched!.TemplateId);
|
||||
|
||||
var listed = await _repository.ListAsync("tenant-a");
|
||||
Assert.Single(listed);
|
||||
|
||||
await _repository.DeleteAsync("tenant-a", "template-1");
|
||||
Assert.Null(await _repository.GetAsync("tenant-a", "template-1"));
|
||||
}
|
||||
|
||||
private static NotifyMongoInitializer CreateInitializer(NotifyMongoContext context)
|
||||
{
|
||||
var migrations = new INotifyMongoMigration[]
|
||||
{
|
||||
new EnsureNotifyCollectionsMigration(NullLogger<EnsureNotifyCollectionsMigration>.Instance),
|
||||
new EnsureNotifyIndexesMigration()
|
||||
};
|
||||
|
||||
var runner = new NotifyMongoMigrationRunner(context, migrations, NullLogger<NotifyMongoMigrationRunner>.Instance);
|
||||
return new NotifyMongoInitializer(context, runner, NullLogger<NotifyMongoInitializer>.Instance);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
using System.Text.Json.Nodes;
|
||||
using StellaOps.Notify.Models;
|
||||
using StellaOps.Notify.Storage.Mongo.Serialization;
|
||||
|
||||
namespace StellaOps.Notify.Storage.Mongo.Tests.Serialization;
|
||||
|
||||
public sealed class NotifyChannelDocumentMapperTests
|
||||
{
|
||||
[Fact]
|
||||
public void RoundTripSampleChannelMaintainsCanonicalShape()
|
||||
{
|
||||
var sample = LoadSample("notify-channel@1.sample.json");
|
||||
var node = JsonNode.Parse(sample) ?? throw new InvalidOperationException("Sample JSON null.");
|
||||
|
||||
var channel = NotifySchemaMigration.UpgradeChannel(node);
|
||||
var bson = NotifyChannelDocumentMapper.ToBsonDocument(channel);
|
||||
var restored = NotifyChannelDocumentMapper.FromBsonDocument(bson);
|
||||
|
||||
var canonical = NotifyCanonicalJsonSerializer.Serialize(restored);
|
||||
var canonicalNode = JsonNode.Parse(canonical) ?? throw new InvalidOperationException("Canonical JSON null.");
|
||||
|
||||
Assert.True(JsonNode.DeepEquals(node, canonicalNode), "Canonical JSON should match sample document.");
|
||||
}
|
||||
|
||||
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,36 @@
|
||||
using System.Text.Json.Nodes;
|
||||
using MongoDB.Bson;
|
||||
using StellaOps.Notify.Models;
|
||||
using StellaOps.Notify.Storage.Mongo.Serialization;
|
||||
|
||||
namespace StellaOps.Notify.Storage.Mongo.Tests.Serialization;
|
||||
|
||||
public sealed class NotifyRuleDocumentMapperTests
|
||||
{
|
||||
[Fact]
|
||||
public void RoundTripSampleRuleMaintainsCanonicalShape()
|
||||
{
|
||||
var sample = LoadSample("notify-rule@1.sample.json");
|
||||
var node = JsonNode.Parse(sample) ?? throw new InvalidOperationException("Sample JSON null.");
|
||||
|
||||
var rule = NotifySchemaMigration.UpgradeRule(node);
|
||||
var bson = NotifyRuleDocumentMapper.ToBsonDocument(rule);
|
||||
var restored = NotifyRuleDocumentMapper.FromBsonDocument(bson);
|
||||
|
||||
var canonical = NotifyCanonicalJsonSerializer.Serialize(restored);
|
||||
var canonicalNode = JsonNode.Parse(canonical) ?? throw new InvalidOperationException("Canonical JSON null.");
|
||||
|
||||
Assert.True(JsonNode.DeepEquals(node, canonicalNode), "Canonical JSON should match sample document.");
|
||||
}
|
||||
|
||||
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,35 @@
|
||||
using System.Text.Json.Nodes;
|
||||
using StellaOps.Notify.Models;
|
||||
using StellaOps.Notify.Storage.Mongo.Serialization;
|
||||
|
||||
namespace StellaOps.Notify.Storage.Mongo.Tests.Serialization;
|
||||
|
||||
public sealed class NotifyTemplateDocumentMapperTests
|
||||
{
|
||||
[Fact]
|
||||
public void RoundTripSampleTemplateMaintainsCanonicalShape()
|
||||
{
|
||||
var sample = LoadSample("notify-template@1.sample.json");
|
||||
var node = JsonNode.Parse(sample) ?? throw new InvalidOperationException("Sample JSON null.");
|
||||
|
||||
var template = NotifySchemaMigration.UpgradeTemplate(node);
|
||||
var bson = NotifyTemplateDocumentMapper.ToBsonDocument(template);
|
||||
var restored = NotifyTemplateDocumentMapper.FromBsonDocument(bson);
|
||||
|
||||
var canonical = NotifyCanonicalJsonSerializer.Serialize(restored);
|
||||
var canonicalNode = JsonNode.Parse(canonical) ?? throw new InvalidOperationException("Canonical JSON null.");
|
||||
|
||||
Assert.True(JsonNode.DeepEquals(node, canonicalNode), "Canonical JSON should match sample document.");
|
||||
}
|
||||
|
||||
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,29 @@
|
||||
<?xml version='1.0' encoding='utf-8'?>
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
<UseConcelierTestInfra>false</UseConcelierTestInfra>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="../../__Libraries/StellaOps.Notify.Models/StellaOps.Notify.Models.csproj" />
|
||||
<ProjectReference Include="../../__Libraries/StellaOps.Notify.Storage.Mongo/StellaOps.Notify.Storage.Mongo.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="coverlet.collector" Version="6.0.4" />
|
||||
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.14.0" />
|
||||
<PackageReference Include="Mongo2Go" Version="3.1.3" />
|
||||
<PackageReference Include="MongoDB.Bson" Version="3.5.0" />
|
||||
<PackageReference Include="xunit" Version="2.9.2" />
|
||||
<PackageReference Include="xunit.runner.visualstudio" Version="2.8.2" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<None Include="../../docs/notify/samples/*.json">
|
||||
<CopyToOutputDirectory>Always</CopyToOutputDirectory>
|
||||
</None>
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
@@ -0,0 +1,417 @@
|
||||
using System.Collections.Generic;
|
||||
using System.IdentityModel.Tokens.Jwt;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Net;
|
||||
using System.Net.Http;
|
||||
using System.Net.Http.Headers;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using System.Text.Json.Nodes;
|
||||
using Microsoft.AspNetCore.Mvc.Testing;
|
||||
using Microsoft.IdentityModel.Tokens;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using StellaOps.Notify.Engine;
|
||||
using StellaOps.Notify.Models;
|
||||
|
||||
namespace StellaOps.Notify.WebService.Tests;
|
||||
|
||||
public sealed class CrudEndpointsTests : IClassFixture<WebApplicationFactory<Program>>, IAsyncLifetime
|
||||
{
|
||||
private const string SigningKey = "super-secret-test-key-1234567890";
|
||||
private const string Issuer = "test-issuer";
|
||||
private const string Audience = "notify";
|
||||
|
||||
private readonly WebApplicationFactory<Program> _factory;
|
||||
private readonly string _adminToken;
|
||||
private readonly string _readToken;
|
||||
|
||||
public CrudEndpointsTests(WebApplicationFactory<Program> factory)
|
||||
{
|
||||
_factory = factory.WithWebHostBuilder(builder =>
|
||||
{
|
||||
builder.UseSetting("notify:storage:driver", "memory");
|
||||
builder.UseSetting("notify:authority:enabled", "false");
|
||||
builder.UseSetting("notify:authority:developmentSigningKey", SigningKey);
|
||||
builder.UseSetting("notify:authority:issuer", Issuer);
|
||||
builder.UseSetting("notify:authority:audiences:0", Audience);
|
||||
builder.UseSetting("notify:authority:adminScope", "notify.admin");
|
||||
builder.UseSetting("notify:authority:readScope", "notify.read");
|
||||
builder.UseSetting("notify:telemetry:enableRequestLogging", "false");
|
||||
builder.UseSetting("notify:api:rateLimits:testSend:tokenLimit", "10");
|
||||
builder.UseSetting("notify:api:rateLimits:testSend:tokensPerPeriod", "10");
|
||||
builder.UseSetting("notify:api:rateLimits:testSend:queueLimit", "5");
|
||||
builder.UseSetting("notify:api:rateLimits:deliveryHistory:tokenLimit", "30");
|
||||
builder.UseSetting("notify:api:rateLimits:deliveryHistory:tokensPerPeriod", "30");
|
||||
builder.UseSetting("notify:api:rateLimits:deliveryHistory:queueLimit", "10");
|
||||
});
|
||||
|
||||
_adminToken = CreateToken("notify.admin");
|
||||
_readToken = CreateToken("notify.read");
|
||||
}
|
||||
|
||||
public Task InitializeAsync() => Task.CompletedTask;
|
||||
|
||||
public Task DisposeAsync() => Task.CompletedTask;
|
||||
|
||||
[Fact]
|
||||
public async Task RuleCrudLifecycle()
|
||||
{
|
||||
var client = _factory.CreateClient();
|
||||
var payload = LoadSample("notify-rule@1.sample.json");
|
||||
payload["ruleId"] = "rule-web";
|
||||
payload["tenantId"] = "tenant-web";
|
||||
payload["actions"]!.AsArray()[0]! ["actionId"] = "action-web";
|
||||
|
||||
await PostAsync(client, "/api/v1/notify/rules", payload);
|
||||
|
||||
var list = await GetJsonArrayAsync(client, "/api/v1/notify/rules", useAdminToken: false);
|
||||
Assert.Equal("rule-web", list?[0]? ["ruleId"]?.GetValue<string>());
|
||||
|
||||
var single = await GetJsonObjectAsync(client, "/api/v1/notify/rules/rule-web", useAdminToken: false);
|
||||
Assert.Equal("tenant-web", single? ["tenantId"]?.GetValue<string>());
|
||||
|
||||
await DeleteAsync(client, "/api/v1/notify/rules/rule-web");
|
||||
var afterDelete = await SendAsync(client, HttpMethod.Get, "/api/v1/notify/rules/rule-web", useAdminToken: false);
|
||||
Assert.Equal(HttpStatusCode.NotFound, afterDelete.StatusCode);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ChannelTemplateDeliveryAndAuditFlows()
|
||||
{
|
||||
var client = _factory.CreateClient();
|
||||
|
||||
var channelPayload = LoadSample("notify-channel@1.sample.json");
|
||||
channelPayload["channelId"] = "channel-web";
|
||||
channelPayload["tenantId"] = "tenant-web";
|
||||
await PostAsync(client, "/api/v1/notify/channels", channelPayload);
|
||||
|
||||
var templatePayload = LoadSample("notify-template@1.sample.json");
|
||||
templatePayload["templateId"] = "template-web";
|
||||
templatePayload["tenantId"] = "tenant-web";
|
||||
await PostAsync(client, "/api/v1/notify/templates", templatePayload);
|
||||
|
||||
var delivery = NotifyDelivery.Create(
|
||||
deliveryId: "delivery-web",
|
||||
tenantId: "tenant-web",
|
||||
ruleId: "rule-web",
|
||||
actionId: "channel-web",
|
||||
eventId: Guid.NewGuid(),
|
||||
kind: NotifyEventKinds.ScannerReportReady,
|
||||
status: NotifyDeliveryStatus.Sent,
|
||||
createdAt: DateTimeOffset.UtcNow,
|
||||
sentAt: DateTimeOffset.UtcNow);
|
||||
|
||||
var deliveryNode = JsonNode.Parse(NotifyCanonicalJsonSerializer.Serialize(delivery))!;
|
||||
await PostAsync(client, "/api/v1/notify/deliveries", deliveryNode);
|
||||
|
||||
var deliveriesEnvelope = await GetJsonObjectAsync(client, "/api/v1/notify/deliveries?limit=10", useAdminToken: false);
|
||||
Assert.NotNull(deliveriesEnvelope);
|
||||
Assert.Equal(1, deliveriesEnvelope? ["count"]?.GetValue<int>());
|
||||
Assert.Null(deliveriesEnvelope? ["continuationToken"]?.GetValue<string>());
|
||||
var deliveries = deliveriesEnvelope? ["items"] as JsonArray;
|
||||
Assert.NotNull(deliveries);
|
||||
Assert.NotEmpty(deliveries!.OfType<JsonNode>());
|
||||
|
||||
var digestNode = new JsonObject
|
||||
{
|
||||
["tenantId"] = "tenant-web",
|
||||
["actionKey"] = "channel-web",
|
||||
["window"] = "hourly",
|
||||
["openedAt"] = DateTimeOffset.UtcNow.ToString("O"),
|
||||
["status"] = "open",
|
||||
["items"] = new JsonArray()
|
||||
};
|
||||
await PostAsync(client, "/api/v1/notify/digests", digestNode);
|
||||
|
||||
var digest = await GetJsonObjectAsync(client, "/api/v1/notify/digests/channel-web", useAdminToken: false);
|
||||
Assert.Equal("channel-web", digest? ["actionKey"]?.GetValue<string>());
|
||||
|
||||
var auditPayload = JsonNode.Parse("""
|
||||
{
|
||||
"action": "create-rule",
|
||||
"entityType": "rule",
|
||||
"entityId": "rule-web",
|
||||
"payload": {"ruleId": "rule-web"}
|
||||
}
|
||||
""")!;
|
||||
await PostAsync(client, "/api/v1/notify/audit", auditPayload);
|
||||
|
||||
var audits = await GetJsonArrayAsync(client, "/api/v1/notify/audit", useAdminToken: false);
|
||||
Assert.NotNull(audits);
|
||||
Assert.Contains(audits!.OfType<JsonObject>(), entry => entry?["action"]?.GetValue<string>() == "create-rule");
|
||||
|
||||
await DeleteAsync(client, "/api/v1/notify/digests/channel-web");
|
||||
var digestAfterDelete = await SendAsync(client, HttpMethod.Get, "/api/v1/notify/digests/channel-web", useAdminToken: false);
|
||||
Assert.Equal(HttpStatusCode.NotFound, digestAfterDelete.StatusCode);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task LockEndpointsAllowAcquireAndRelease()
|
||||
{
|
||||
var client = _factory.CreateClient();
|
||||
var acquirePayload = JsonNode.Parse("""
|
||||
{
|
||||
"resource": "workers",
|
||||
"owner": "worker-1",
|
||||
"ttlSeconds": 30
|
||||
}
|
||||
""")!;
|
||||
|
||||
var acquireResponse = await PostAsync(client, "/api/v1/notify/locks/acquire", acquirePayload);
|
||||
var acquireContent = JsonNode.Parse(await acquireResponse.Content.ReadAsStringAsync());
|
||||
Assert.True(acquireContent? ["acquired"]?.GetValue<bool>());
|
||||
|
||||
await PostAsync(client, "/api/v1/notify/locks/release", JsonNode.Parse("""
|
||||
{
|
||||
"resource": "workers",
|
||||
"owner": "worker-1"
|
||||
}
|
||||
""")!);
|
||||
|
||||
var secondAcquire = await PostAsync(client, "/api/v1/notify/locks/acquire", acquirePayload);
|
||||
var secondContent = JsonNode.Parse(await secondAcquire.Content.ReadAsStringAsync());
|
||||
Assert.True(secondContent? ["acquired"]?.GetValue<bool>());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ChannelTestSendReturnsPreview()
|
||||
{
|
||||
var client = _factory.CreateClient();
|
||||
|
||||
var channelPayload = LoadSample("notify-channel@1.sample.json");
|
||||
channelPayload["channelId"] = "channel-test";
|
||||
channelPayload["tenantId"] = "tenant-web";
|
||||
channelPayload["config"]! ["target"] = "#ops-alerts";
|
||||
await PostAsync(client, "/api/v1/notify/channels", channelPayload);
|
||||
|
||||
var payload = JsonNode.Parse("""
|
||||
{
|
||||
"target": "#ops-alerts",
|
||||
"title": "Smoke test",
|
||||
"body": "Sample body"
|
||||
}
|
||||
""")!;
|
||||
|
||||
var response = await PostAsync(client, "/api/v1/notify/channels/channel-test/test", payload);
|
||||
Assert.Equal(HttpStatusCode.Accepted, response.StatusCode);
|
||||
|
||||
var json = JsonNode.Parse(await response.Content.ReadAsStringAsync())!.AsObject();
|
||||
Assert.Equal("tenant-web", json["tenantId"]?.GetValue<string>());
|
||||
Assert.Equal("channel-test", json["channelId"]?.GetValue<string>());
|
||||
Assert.NotNull(json["queuedAt"]);
|
||||
Assert.NotNull(json["traceId"]);
|
||||
|
||||
var preview = json["preview"]?.AsObject();
|
||||
Assert.NotNull(preview);
|
||||
Assert.Equal("#ops-alerts", preview? ["target"]?.GetValue<string>());
|
||||
Assert.Equal("Smoke test", preview? ["title"]?.GetValue<string>());
|
||||
Assert.Equal("Sample body", preview? ["body"]?.GetValue<string>());
|
||||
|
||||
var expectedHash = Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes("Sample body"))).ToLowerInvariant();
|
||||
Assert.Equal(expectedHash, preview? ["bodyHash"]?.GetValue<string>());
|
||||
|
||||
var metadata = json["metadata"] as JsonObject;
|
||||
Assert.NotNull(metadata);
|
||||
Assert.Equal("#ops-alerts", metadata?["target"]?.GetValue<string>());
|
||||
Assert.Equal("slack", metadata?["channelType"]?.GetValue<string>());
|
||||
Assert.Equal("fallback", metadata?["previewProvider"]?.GetValue<string>());
|
||||
Assert.Equal(json["traceId"]?.GetValue<string>(), metadata?["traceId"]?.GetValue<string>());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ChannelTestSendHonoursRateLimit()
|
||||
{
|
||||
using var limitedFactory = _factory.WithWebHostBuilder(builder =>
|
||||
{
|
||||
builder.UseSetting("notify:api:rateLimits:testSend:tokenLimit", "1");
|
||||
builder.UseSetting("notify:api:rateLimits:testSend:tokensPerPeriod", "1");
|
||||
builder.UseSetting("notify:api:rateLimits:testSend:queueLimit", "0");
|
||||
});
|
||||
|
||||
var client = limitedFactory.CreateClient();
|
||||
|
||||
var channelPayload = LoadSample("notify-channel@1.sample.json");
|
||||
channelPayload["channelId"] = "channel-rate-limit";
|
||||
channelPayload["tenantId"] = "tenant-web";
|
||||
channelPayload["config"]! ["target"] = "#ops-alerts";
|
||||
await PostAsync(client, "/api/v1/notify/channels", channelPayload);
|
||||
|
||||
var payload = JsonNode.Parse("""
|
||||
{
|
||||
"body": "First"
|
||||
}
|
||||
""")!;
|
||||
|
||||
var first = await PostAsync(client, "/api/v1/notify/channels/channel-rate-limit/test", payload);
|
||||
Assert.Equal(HttpStatusCode.Accepted, first.StatusCode);
|
||||
|
||||
var secondRequest = new HttpRequestMessage(HttpMethod.Post, "/api/v1/notify/channels/channel-rate-limit/test")
|
||||
{
|
||||
Content = new StringContent(payload.ToJsonString(), Encoding.UTF8, "application/json")
|
||||
};
|
||||
|
||||
var second = await SendAsync(client, secondRequest);
|
||||
Assert.Equal(HttpStatusCode.TooManyRequests, second.StatusCode);
|
||||
Assert.NotNull(second.Headers.RetryAfter);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ChannelTestSendUsesRegisteredProvider()
|
||||
{
|
||||
var providerName = typeof(FakeSlackTestProvider).FullName!;
|
||||
|
||||
using var providerFactory = _factory.WithWebHostBuilder(builder =>
|
||||
{
|
||||
builder.ConfigureServices(services =>
|
||||
{
|
||||
services.AddSingleton<INotifyChannelTestProvider, FakeSlackTestProvider>();
|
||||
});
|
||||
});
|
||||
|
||||
var client = providerFactory.CreateClient();
|
||||
|
||||
var channelPayload = LoadSample("notify-channel@1.sample.json");
|
||||
channelPayload["channelId"] = "channel-provider";
|
||||
channelPayload["tenantId"] = "tenant-web";
|
||||
channelPayload["config"]! ["target"] = "#ops-alerts";
|
||||
await PostAsync(client, "/api/v1/notify/channels", channelPayload);
|
||||
|
||||
var payload = JsonNode.Parse("""
|
||||
{
|
||||
"target": "#ops-alerts",
|
||||
"title": "Provider Title",
|
||||
"summary": "Provider Summary"
|
||||
}
|
||||
""")!;
|
||||
|
||||
var response = await PostAsync(client, "/api/v1/notify/channels/channel-provider/test", payload);
|
||||
Assert.Equal(HttpStatusCode.Accepted, response.StatusCode);
|
||||
|
||||
var json = JsonNode.Parse(await response.Content.ReadAsStringAsync())!.AsObject();
|
||||
var preview = json["preview"]?.AsObject();
|
||||
Assert.NotNull(preview);
|
||||
Assert.Equal("#ops-alerts", preview?["target"]?.GetValue<string>());
|
||||
Assert.Equal("Provider Title", preview?["title"]?.GetValue<string>());
|
||||
Assert.Equal("{\"provider\":\"fake\"}", preview?["body"]?.GetValue<string>());
|
||||
|
||||
var metadata = json["metadata"]?.AsObject();
|
||||
Assert.NotNull(metadata);
|
||||
Assert.Equal(providerName, metadata?["previewProvider"]?.GetValue<string>());
|
||||
Assert.Equal("fake-provider", metadata?["provider.name"]?.GetValue<string>());
|
||||
}
|
||||
|
||||
private sealed class FakeSlackTestProvider : INotifyChannelTestProvider
|
||||
{
|
||||
public NotifyChannelType ChannelType => NotifyChannelType.Slack;
|
||||
|
||||
public Task<ChannelTestPreviewResult> BuildPreviewAsync(ChannelTestPreviewContext context, CancellationToken cancellationToken)
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
|
||||
var body = "{\"provider\":\"fake\"}";
|
||||
var preview = NotifyDeliveryRendered.Create(
|
||||
NotifyChannelType.Slack,
|
||||
NotifyDeliveryFormat.Slack,
|
||||
context.Target,
|
||||
context.Request.Title ?? "Provider Title",
|
||||
body,
|
||||
context.Request.Summary ?? "Provider Summary",
|
||||
context.Request.TextBody,
|
||||
context.Request.Locale,
|
||||
ChannelTestPreviewUtilities.ComputeBodyHash(body),
|
||||
context.Request.Attachments);
|
||||
|
||||
var metadata = new Dictionary<string, string>(StringComparer.Ordinal)
|
||||
{
|
||||
["provider.name"] = "fake-provider"
|
||||
};
|
||||
|
||||
return Task.FromResult(new ChannelTestPreviewResult(preview, metadata));
|
||||
}
|
||||
}
|
||||
|
||||
private static JsonNode LoadSample(string fileName)
|
||||
{
|
||||
var path = Path.Combine(AppContext.BaseDirectory, fileName);
|
||||
if (!File.Exists(path))
|
||||
{
|
||||
throw new FileNotFoundException($"Unable to load sample '{fileName}'.", path);
|
||||
}
|
||||
|
||||
return JsonNode.Parse(File.ReadAllText(path)) ?? throw new InvalidOperationException("Sample JSON null.");
|
||||
}
|
||||
|
||||
private async Task<JsonArray?> GetJsonArrayAsync(HttpClient client, string path, bool useAdminToken)
|
||||
{
|
||||
var response = await SendAsync(client, HttpMethod.Get, path, useAdminToken);
|
||||
response.EnsureSuccessStatusCode();
|
||||
var content = await response.Content.ReadAsStringAsync();
|
||||
return JsonNode.Parse(content) as JsonArray;
|
||||
}
|
||||
|
||||
private async Task<JsonObject?> GetJsonObjectAsync(HttpClient client, string path, bool useAdminToken)
|
||||
{
|
||||
var response = await SendAsync(client, HttpMethod.Get, path, useAdminToken);
|
||||
response.EnsureSuccessStatusCode();
|
||||
var content = await response.Content.ReadAsStringAsync();
|
||||
return JsonNode.Parse(content) as JsonObject;
|
||||
}
|
||||
|
||||
private async Task<HttpResponseMessage> PostAsync(HttpClient client, string path, JsonNode payload, bool useAdminToken = true)
|
||||
{
|
||||
var request = new HttpRequestMessage(HttpMethod.Post, path)
|
||||
{
|
||||
Content = new StringContent(payload.ToJsonString(), Encoding.UTF8, "application/json")
|
||||
};
|
||||
|
||||
var response = await SendAsync(client, request, useAdminToken);
|
||||
if (!response.IsSuccessStatusCode)
|
||||
{
|
||||
var body = await response.Content.ReadAsStringAsync();
|
||||
throw new InvalidOperationException($"Request to {path} failed with {(int)response.StatusCode} {response.StatusCode}: {body}");
|
||||
}
|
||||
|
||||
return response;
|
||||
}
|
||||
|
||||
private Task<HttpResponseMessage> PostAsync(HttpClient client, string path, JsonNode payload)
|
||||
=> PostAsync(client, path, payload, useAdminToken: true);
|
||||
|
||||
private async Task DeleteAsync(HttpClient client, string path)
|
||||
{
|
||||
var response = await SendAsync(client, HttpMethod.Delete, path);
|
||||
response.EnsureSuccessStatusCode();
|
||||
}
|
||||
|
||||
private Task<HttpResponseMessage> SendAsync(HttpClient client, HttpMethod method, string path, bool useAdminToken = true)
|
||||
=> SendAsync(client, new HttpRequestMessage(method, path), useAdminToken);
|
||||
|
||||
private Task<HttpResponseMessage> SendAsync(HttpClient client, HttpRequestMessage request, bool useAdminToken = true)
|
||||
{
|
||||
request.Headers.Add("X-StellaOps-Tenant", "tenant-web");
|
||||
request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", useAdminToken ? _adminToken : _readToken);
|
||||
return client.SendAsync(request);
|
||||
}
|
||||
|
||||
private static string CreateToken(string scope)
|
||||
{
|
||||
var handler = new JwtSecurityTokenHandler();
|
||||
var key = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(SigningKey));
|
||||
var descriptor = new SecurityTokenDescriptor
|
||||
{
|
||||
Issuer = Issuer,
|
||||
Audience = Audience,
|
||||
Expires = DateTime.UtcNow.AddMinutes(10),
|
||||
SigningCredentials = new SigningCredentials(key, SecurityAlgorithms.HmacSha256),
|
||||
Subject = new System.Security.Claims.ClaimsIdentity(new[]
|
||||
{
|
||||
new System.Security.Claims.Claim("scope", scope),
|
||||
new System.Security.Claims.Claim(System.Security.Claims.ClaimTypes.Name, "integration-test")
|
||||
})
|
||||
};
|
||||
|
||||
var token = handler.CreateToken(descriptor);
|
||||
return handler.WriteToken(token);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
using System.Net.Http.Json;
|
||||
using System.Text.Json.Nodes;
|
||||
using Microsoft.AspNetCore.Mvc.Testing;
|
||||
|
||||
namespace StellaOps.Notify.WebService.Tests;
|
||||
|
||||
public sealed class NormalizeEndpointsTests : IClassFixture<WebApplicationFactory<Program>>, IAsyncLifetime
|
||||
{
|
||||
private readonly WebApplicationFactory<Program> _factory;
|
||||
|
||||
public NormalizeEndpointsTests(WebApplicationFactory<Program> factory)
|
||||
{
|
||||
_factory = factory.WithWebHostBuilder(builder =>
|
||||
{
|
||||
builder.UseSetting("notify:storage:driver", "memory");
|
||||
builder.UseSetting("notify:authority:enabled", "false");
|
||||
builder.UseSetting("notify:authority:developmentSigningKey", "normalize-tests-signing-key-1234567890");
|
||||
builder.UseSetting("notify:authority:issuer", "test-issuer");
|
||||
builder.UseSetting("notify:authority:audiences:0", "notify");
|
||||
builder.UseSetting("notify:telemetry:enableRequestLogging", "false");
|
||||
});
|
||||
}
|
||||
|
||||
public Task InitializeAsync() => Task.CompletedTask;
|
||||
|
||||
public Task DisposeAsync() => Task.CompletedTask;
|
||||
|
||||
[Fact]
|
||||
public async Task RuleNormalizeAddsSchemaVersion()
|
||||
{
|
||||
var client = _factory.CreateClient();
|
||||
var payload = LoadSampleNode("notify-rule@1.sample.json");
|
||||
payload!.AsObject().Remove("schemaVersion");
|
||||
|
||||
var response = await client.PostAsJsonAsync("/internal/notify/rules/normalize", payload);
|
||||
response.EnsureSuccessStatusCode();
|
||||
|
||||
var content = await response.Content.ReadAsStringAsync();
|
||||
var normalized = JsonNode.Parse(content);
|
||||
|
||||
Assert.Equal("notify.rule@1", normalized?["schemaVersion"]?.GetValue<string>());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ChannelNormalizeAddsSchemaVersion()
|
||||
{
|
||||
var client = _factory.CreateClient();
|
||||
var payload = LoadSampleNode("notify-channel@1.sample.json");
|
||||
payload!.AsObject().Remove("schemaVersion");
|
||||
|
||||
var response = await client.PostAsJsonAsync("/internal/notify/channels/normalize", payload);
|
||||
response.EnsureSuccessStatusCode();
|
||||
|
||||
var content = await response.Content.ReadAsStringAsync();
|
||||
var normalized = JsonNode.Parse(content);
|
||||
|
||||
Assert.Equal("notify.channel@1", normalized?["schemaVersion"]?.GetValue<string>());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task TemplateNormalizeAddsSchemaVersion()
|
||||
{
|
||||
var client = _factory.CreateClient();
|
||||
var payload = LoadSampleNode("notify-template@1.sample.json");
|
||||
payload!.AsObject().Remove("schemaVersion");
|
||||
|
||||
var response = await client.PostAsJsonAsync("/internal/notify/templates/normalize", payload);
|
||||
response.EnsureSuccessStatusCode();
|
||||
|
||||
var content = await response.Content.ReadAsStringAsync();
|
||||
var normalized = JsonNode.Parse(content);
|
||||
|
||||
Assert.Equal("notify.template@1", normalized?["schemaVersion"]?.GetValue<string>());
|
||||
}
|
||||
|
||||
private static JsonNode? LoadSampleNode(string fileName)
|
||||
{
|
||||
var path = Path.Combine(AppContext.BaseDirectory, fileName);
|
||||
if (!File.Exists(path))
|
||||
{
|
||||
throw new FileNotFoundException($"Unable to load sample '{fileName}'.", path);
|
||||
}
|
||||
|
||||
return JsonNode.Parse(File.ReadAllText(path));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
<?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" />
|
||||
<ProjectReference Include="../../StellaOps.Notify.WebService/StellaOps.Notify.WebService.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<None Include="../../docs/notify/samples/*.json">
|
||||
<CopyToOutputDirectory>Always</CopyToOutputDirectory>
|
||||
</None>
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
@@ -0,0 +1,167 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using FluentAssertions;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Microsoft.Extensions.Options;
|
||||
using StellaOps.Notify.Models;
|
||||
using StellaOps.Notify.Queue;
|
||||
using StellaOps.Notify.Worker;
|
||||
using StellaOps.Notify.Worker.Handlers;
|
||||
using StellaOps.Notify.Worker.Processing;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Notify.Worker.Tests;
|
||||
|
||||
public sealed class NotifyEventLeaseProcessorTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task ProcessOnce_ShouldAcknowledgeSuccessfulLease()
|
||||
{
|
||||
var lease = new FakeLease();
|
||||
var queue = new FakeEventQueue(lease);
|
||||
var handler = new TestHandler();
|
||||
var options = Options.Create(new NotifyWorkerOptions { LeaseBatchSize = 1, LeaseDuration = TimeSpan.FromSeconds(5) });
|
||||
var processor = new NotifyEventLeaseProcessor(queue, handler, options, NullLogger<NotifyEventLeaseProcessor>.Instance, TimeProvider.System);
|
||||
|
||||
var processed = await processor.ProcessOnceAsync(CancellationToken.None);
|
||||
|
||||
processed.Should().Be(1);
|
||||
lease.AcknowledgeCount.Should().Be(1);
|
||||
lease.ReleaseCount.Should().Be(0);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ProcessOnce_ShouldRetryOnHandlerFailure()
|
||||
{
|
||||
var lease = new FakeLease();
|
||||
var queue = new FakeEventQueue(lease);
|
||||
var handler = new TestHandler(shouldThrow: true);
|
||||
var options = Options.Create(new NotifyWorkerOptions { LeaseBatchSize = 1, LeaseDuration = TimeSpan.FromSeconds(5) });
|
||||
var processor = new NotifyEventLeaseProcessor(queue, handler, options, NullLogger<NotifyEventLeaseProcessor>.Instance, TimeProvider.System);
|
||||
|
||||
var processed = await processor.ProcessOnceAsync(CancellationToken.None);
|
||||
|
||||
processed.Should().Be(1);
|
||||
lease.AcknowledgeCount.Should().Be(0);
|
||||
lease.ReleaseCount.Should().Be(1);
|
||||
lease.LastDisposition.Should().Be(NotifyQueueReleaseDisposition.Retry);
|
||||
}
|
||||
|
||||
private sealed class FakeEventQueue : INotifyEventQueue
|
||||
{
|
||||
private readonly Queue<INotifyQueueLease<NotifyQueueEventMessage>> _leases;
|
||||
|
||||
public FakeEventQueue(params INotifyQueueLease<NotifyQueueEventMessage>[] leases)
|
||||
{
|
||||
_leases = new Queue<INotifyQueueLease<NotifyQueueEventMessage>>(leases);
|
||||
}
|
||||
|
||||
public ValueTask<NotifyQueueEnqueueResult> PublishAsync(NotifyQueueEventMessage message, CancellationToken cancellationToken = default)
|
||||
=> throw new NotSupportedException();
|
||||
|
||||
public ValueTask<IReadOnlyList<INotifyQueueLease<NotifyQueueEventMessage>>> LeaseAsync(NotifyQueueLeaseRequest request, CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (_leases.Count == 0)
|
||||
{
|
||||
return ValueTask.FromResult<IReadOnlyList<INotifyQueueLease<NotifyQueueEventMessage>>>(Array.Empty<INotifyQueueLease<NotifyQueueEventMessage>>());
|
||||
}
|
||||
|
||||
return ValueTask.FromResult<IReadOnlyList<INotifyQueueLease<NotifyQueueEventMessage>>>(new[] { _leases.Dequeue() });
|
||||
}
|
||||
|
||||
public ValueTask<IReadOnlyList<INotifyQueueLease<NotifyQueueEventMessage>>> ClaimExpiredAsync(NotifyQueueClaimOptions options, CancellationToken cancellationToken = default)
|
||||
=> ValueTask.FromResult<IReadOnlyList<INotifyQueueLease<NotifyQueueEventMessage>>>(Array.Empty<INotifyQueueLease<NotifyQueueEventMessage>>());
|
||||
}
|
||||
|
||||
private sealed class FakeLease : INotifyQueueLease<NotifyQueueEventMessage>
|
||||
{
|
||||
private readonly NotifyQueueEventMessage _message;
|
||||
|
||||
public FakeLease()
|
||||
{
|
||||
var notifyEvent = NotifyEvent.Create(
|
||||
Guid.NewGuid(),
|
||||
kind: "test.event",
|
||||
tenant: "tenant-1",
|
||||
ts: DateTimeOffset.UtcNow,
|
||||
payload: null);
|
||||
|
||||
_message = new NotifyQueueEventMessage(notifyEvent, "notify:events", traceId: "trace-123");
|
||||
}
|
||||
|
||||
public string MessageId { get; } = Guid.NewGuid().ToString("n");
|
||||
|
||||
public int Attempt { get; internal set; } = 1;
|
||||
|
||||
public DateTimeOffset EnqueuedAt { get; } = DateTimeOffset.UtcNow;
|
||||
|
||||
public DateTimeOffset LeaseExpiresAt { get; private set; } = DateTimeOffset.UtcNow.AddSeconds(30);
|
||||
|
||||
public string Consumer { get; } = "worker-1";
|
||||
|
||||
public string Stream => _message.Stream;
|
||||
|
||||
public string TenantId => _message.TenantId;
|
||||
|
||||
public string? PartitionKey => _message.PartitionKey;
|
||||
|
||||
public string IdempotencyKey => _message.IdempotencyKey;
|
||||
|
||||
public string? TraceId => _message.TraceId;
|
||||
|
||||
public IReadOnlyDictionary<string, string> Attributes => _message.Attributes;
|
||||
|
||||
public NotifyQueueEventMessage Message => _message;
|
||||
|
||||
public int AcknowledgeCount { get; private set; }
|
||||
|
||||
public int ReleaseCount { get; private set; }
|
||||
|
||||
public NotifyQueueReleaseDisposition? LastDisposition { get; private set; }
|
||||
|
||||
public Task AcknowledgeAsync(CancellationToken cancellationToken = default)
|
||||
{
|
||||
AcknowledgeCount++;
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
public Task RenewAsync(TimeSpan leaseDuration, CancellationToken cancellationToken = default)
|
||||
{
|
||||
LeaseExpiresAt = DateTimeOffset.UtcNow.Add(leaseDuration);
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
public Task ReleaseAsync(NotifyQueueReleaseDisposition disposition, CancellationToken cancellationToken = default)
|
||||
{
|
||||
LastDisposition = disposition;
|
||||
ReleaseCount++;
|
||||
Attempt++;
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
public Task DeadLetterAsync(string reason, CancellationToken cancellationToken = default)
|
||||
=> Task.CompletedTask;
|
||||
}
|
||||
|
||||
private sealed class TestHandler : INotifyEventHandler
|
||||
{
|
||||
private readonly bool _shouldThrow;
|
||||
|
||||
public TestHandler(bool shouldThrow = false)
|
||||
{
|
||||
_shouldThrow = shouldThrow;
|
||||
}
|
||||
|
||||
public Task HandleAsync(NotifyQueueEventMessage message, CancellationToken cancellationToken)
|
||||
{
|
||||
if (_shouldThrow)
|
||||
{
|
||||
throw new InvalidOperationException("handler failure");
|
||||
}
|
||||
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
<?xml version='1.0' encoding='utf-8'?>
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
<IsPackable>false</IsPackable>
|
||||
<UseConcelierTestInfra>false</UseConcelierTestInfra>
|
||||
</PropertyGroup>
|
||||
<ItemGroup>
|
||||
<PackageReference Include="FluentAssertions" Version="6.12.0" />
|
||||
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.14.0" />
|
||||
<PackageReference Include="xunit" Version="2.9.2" />
|
||||
<PackageReference Include="xunit.runner.visualstudio" Version="2.8.2">
|
||||
<PrivateAssets>all</PrivateAssets>
|
||||
</PackageReference>
|
||||
<PackageReference Include="coverlet.collector" Version="6.0.4">
|
||||
<PrivateAssets>all</PrivateAssets>
|
||||
</PackageReference>
|
||||
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" Version="10.0.0-rc.2.25502.107" />
|
||||
</ItemGroup>
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="../../StellaOps.Notify.Worker/StellaOps.Notify.Worker.csproj" />
|
||||
<ProjectReference Include="../../__Libraries/StellaOps.Notify.Queue/StellaOps.Notify.Queue.csproj" />
|
||||
<ProjectReference Include="../../__Libraries/StellaOps.Notify.Models/StellaOps.Notify.Models.csproj" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
Reference in New Issue
Block a user