tests fixes and some product advisories tunes ups

This commit is contained in:
master
2026-01-30 07:57:43 +02:00
parent 644887997c
commit 55744f6a39
345 changed files with 26290 additions and 2267 deletions

View File

@@ -84,6 +84,7 @@ builder.Host.UseSerilog((context, services, loggerConfiguration) =>
});
builder.Services.AddSingleton(TimeProvider.System);
builder.Services.AddSingleton<StellaOps.Determinism.IGuidProvider, StellaOps.Determinism.SystemGuidProvider>();
builder.Services.AddSingleton<ServiceStatus>();
builder.Services.AddSingleton<NotifySchemaMigrationService>();
@@ -97,7 +98,7 @@ builder.Services.AddSingleton<INotifyPluginRegistry, NotifyPluginRegistry>();
builder.Services.AddSingleton<INotifyChannelTestService, NotifyChannelTestService>();
builder.Services.AddSingleton<INotifyChannelHealthService, NotifyChannelHealthService>();
ConfigureAuthentication(builder, bootstrapOptions);
ConfigureAuthentication(builder, bootstrapOptions, builder.Configuration);
ConfigureRateLimiting(builder, bootstrapOptions);
builder.Services.AddEndpointsApiExplorer();
@@ -125,9 +126,11 @@ app.TryRefreshStellaRouterEndpoints(notifyRouterOptions);
await app.RunAsync();
static void ConfigureAuthentication(WebApplicationBuilder builder, NotifyWebServiceOptions options)
static void ConfigureAuthentication(WebApplicationBuilder builder, NotifyWebServiceOptions options, IConfiguration configuration)
{
if (options.Authority.Enabled)
// Read enabled flag from configuration to support test overrides via UseSetting
var authorityEnabled = configuration.GetValue<bool?>("notify:authority:enabled") ?? options.Authority.Enabled;
if (authorityEnabled)
{
builder.Services.AddStellaOpsResourceServerAuthentication(
builder.Configuration,
@@ -162,7 +165,9 @@ static void ConfigureAuthentication(WebApplicationBuilder builder, NotifyWebServ
}
else
{
if (options.Authority.AllowAnonymousFallback)
// Read allowAnonymousFallback from configuration to support test overrides
var allowAnonymous = configuration.GetValue<bool?>("notify:authority:allowAnonymousFallback") ?? options.Authority.AllowAnonymousFallback;
if (allowAnonymous)
{
builder.Services.AddAuthentication(authOptions =>
{
@@ -194,14 +199,19 @@ static void ConfigureAuthentication(WebApplicationBuilder builder, NotifyWebServ
{
jwt.RequireHttpsMetadata = false;
jwt.IncludeErrorDetails = true;
// Read JWT settings from configuration to support test overrides
var issuer = configuration["notify:authority:issuer"] ?? options.Authority.Issuer;
var audiencesList = configuration.GetSection("notify:authority:audiences").Get<string[]>() ?? options.Authority.Audiences.ToArray();
var signingKey = configuration["notify:authority:developmentSigningKey"] ?? options.Authority.DevelopmentSigningKey!;
jwt.TokenValidationParameters = new TokenValidationParameters
{
ValidateIssuer = true,
ValidIssuer = options.Authority.Issuer,
ValidateAudience = options.Authority.Audiences.Count > 0,
ValidAudiences = options.Authority.Audiences,
ValidIssuer = issuer,
ValidateAudience = audiencesList.Length > 0,
ValidAudiences = audiencesList,
ValidateIssuerSigningKey = true,
IssuerSigningKey = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(options.Authority.DevelopmentSigningKey!)),
IssuerSigningKey = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(signingKey)),
ValidateLifetime = true,
ClockSkew = TimeSpan.FromSeconds(options.Authority.TokenClockSkewSeconds),
NameClaimType = ClaimTypes.Name

View File

@@ -9,8 +9,11 @@
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.IdentityModel.JsonWebTokens" />
<PackageReference Include="Microsoft.IdentityModel.Protocols.OpenIdConnect" />
<PackageReference Include="Serilog.AspNetCore" />
<PackageReference Include="Serilog.Sinks.Console" />
<PackageReference Include="System.IdentityModel.Tokens.Jwt" />
<PackageReference Include="YamlDotNet" />
</ItemGroup>

View File

@@ -73,7 +73,7 @@ internal sealed class NatsNotifyDeliveryQueue : INotifyDeliveryQueue, IAsyncDisp
var publishOpts = new NatsJSPubOpts
{
MsgId = message.IdempotencyKey,
RetryAttempts = 0
RetryAttempts = 3
};
var ack = await js.PublishAsync(

View File

@@ -77,7 +77,7 @@ internal sealed class NatsNotifyEventQueue : INotifyEventQueue, IAsyncDisposable
var publishOpts = new NatsJSPubOpts
{
MsgId = idempotencyKey,
RetryAttempts = 0
RetryAttempts = 3
};
var ack = await js.PublishAsync(

View File

@@ -1,10 +1,6 @@
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 FluentAssertions;
using Microsoft.Extensions.Logging.Abstractions;
using NATS.Client.Core;
@@ -14,52 +10,56 @@ using StellaOps.Notify.Models;
using StellaOps.Notify.Queue;
using StellaOps.Notify.Queue.Nats;
using Xunit;
using Xunit.v3;
using StellaOps.TestKit;
namespace StellaOps.Notify.Queue.Tests;
public sealed class NatsNotifyDeliveryQueueTests : IAsyncLifetime
{
private readonly IContainer _nats;
private const string NatsUrl = "nats://localhost:4222";
private string? _skipReason;
public NatsNotifyDeliveryQueueTests()
{
_nats = new ContainerBuilder()
.WithImage("nats:2.10-alpine")
.WithCleanUp(true)
.WithName($"nats-notify-delivery-{Guid.NewGuid():N}")
.WithPortBinding(4222, true)
.WithCommand("--jetstream")
.WithWaitStrategy(Wait.ForUnixContainer().UntilMessageIsLogged("Server is ready"))
.Build();
}
private readonly string _testId = Guid.NewGuid().ToString("N")[..8];
private string _streamName = null!;
private string _subject = null!;
public async ValueTask InitializeAsync()
{
_streamName = $"NOTIFY_DELIVERY_{_testId}";
_subject = $"notify.delivery.{_testId}";
try
{
await _nats.StartAsync();
await using var connection = new NatsConnection(new NatsOpts { Url = NatsUrl });
await connection.ConnectAsync();
var js = new NatsJSContext(connection);
// Create the stream upfront to ensure it's ready
var streamConfig = new StreamConfig(_streamName, new[] { _subject })
{
Retention = StreamConfigRetention.Workqueue,
Storage = StreamConfigStorage.File
};
try
{
await js.CreateStreamAsync(streamConfig);
}
catch (NatsJSApiException ex) when (ex.Error.ErrCode == 10058) // Stream already exists
{
// Ignore - stream exists from a previous run
}
}
catch (Exception ex)
{
_skipReason = $"NATS-backed delivery tests skipped: {ex.Message}";
_skipReason = $"NATS not available on {NatsUrl}: {ex.Message}";
}
}
public async ValueTask DisposeAsync()
{
if (_skipReason is not null)
{
return;
}
public ValueTask DisposeAsync() => ValueTask.CompletedTask;
await _nats.DisposeAsync().ConfigureAwait(false);
}
[Trait("Category", TestCategories.Unit)]
[Fact]
[Trait("Category", TestCategories.Integration)]
[Fact]
public async Task Publish_ShouldDeduplicate_ByDeliveryId()
{
if (SkipIfUnavailable())
@@ -84,8 +84,8 @@ public sealed class NatsNotifyDeliveryQueueTests : IAsyncLifetime
second.MessageId.Should().Be(first.MessageId);
}
[Trait("Category", TestCategories.Unit)]
[Fact]
[Trait("Category", TestCategories.Integration)]
[Fact]
public async Task Release_Retry_ShouldReschedule()
{
if (SkipIfUnavailable())
@@ -111,8 +111,8 @@ public sealed class NatsNotifyDeliveryQueueTests : IAsyncLifetime
await retried.AcknowledgeAsync(CancellationToken.None);
}
[Trait("Category", TestCategories.Unit)]
[Fact]
[Trait("Category", TestCategories.Integration)]
[Fact]
public async Task Release_RetryBeyondMax_ShouldDeadLetter()
{
if (SkipIfUnavailable())
@@ -120,11 +120,14 @@ public sealed class NatsNotifyDeliveryQueueTests : IAsyncLifetime
return;
}
var options = CreateOptions(static opts =>
var dlqStreamName = $"NOTIFY_DELIVERY_DEAD_{_testId}";
var dlqSubject = $"notify.delivery.{_testId}.dead";
var options = CreateOptions(opts =>
{
opts.MaxDeliveryAttempts = 2;
opts.Nats.DeadLetterStream = "NOTIFY_DELIVERY_DEAD_TEST";
opts.Nats.DeadLetterSubject = "notify.delivery.dead.test";
opts.Nats.DeadLetterStream = dlqStreamName;
opts.Nats.DeadLetterSubject = dlqSubject;
});
await using var queue = CreateQueue(options);
@@ -142,18 +145,18 @@ public sealed class NatsNotifyDeliveryQueueTests : IAsyncLifetime
await Task.Delay(200, CancellationToken.None);
await using var connection = new NatsConnection(new NatsOpts { Url = options.Nats.Url! });
await using var connection = new NatsConnection(new NatsOpts { Url = NatsUrl });
await connection.ConnectAsync();
var js = new NatsJSContext(connection);
// Use ephemeral consumer (no DurableName) - workqueue streams don't support durable consumers
var consumerConfig = new ConsumerConfig
{
DurableName = "notify-delivery-dead-test",
DeliverPolicy = ConsumerConfigDeliverPolicy.All,
AckPolicy = ConsumerConfigAckPolicy.Explicit
};
var consumer = await js.CreateConsumerAsync(options.Nats.DeadLetterStream, consumerConfig, CancellationToken.None);
var consumer = await js.CreateConsumerAsync(dlqStreamName, consumerConfig, CancellationToken.None);
var fetchOpts = new NatsJSFetchOpts { MaxMsgs = 1, Expires = TimeSpan.FromSeconds(1) };
NatsJSMsg<byte[]>? dlqMsg = null;
@@ -169,17 +172,26 @@ public sealed class NatsNotifyDeliveryQueueTests : IAsyncLifetime
private NatsNotifyDeliveryQueue CreateQueue(NotifyDeliveryQueueOptions options)
{
// Use a custom connection factory that pre-connects the connection
async ValueTask<NatsConnection> ConnectionFactory(NatsOpts opts, CancellationToken ct)
{
var connection = new NatsConnection(opts);
await connection.ConnectAsync();
// Give the connection a moment to fully stabilize
await Task.Delay(100, ct);
return connection;
}
return new NatsNotifyDeliveryQueue(
options,
options.Nats,
NullLogger<NatsNotifyDeliveryQueue>.Instance,
TimeProvider.System);
TimeProvider.System,
ConnectionFactory);
}
private NotifyDeliveryQueueOptions CreateOptions(Action<NotifyDeliveryQueueOptions>? configure = null)
{
var url = $"nats://{_nats.Hostname}:{_nats.GetMappedPublicPort(4222)}";
var opts = new NotifyDeliveryQueueOptions
{
Transport = NotifyQueueTransportKind.Nats,
@@ -189,16 +201,16 @@ public sealed class NatsNotifyDeliveryQueueTests : IAsyncLifetime
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",
Url = NatsUrl,
Stream = _streamName,
Subject = _subject,
DeadLetterStream = $"{_streamName}_DEAD",
DeadLetterSubject = $"{_subject}.dead",
DurableConsumer = $"notify-delivery-{_testId}",
MaxAckPending = 32,
AckWait = TimeSpan.FromSeconds(2),
RetryDelay = TimeSpan.FromMilliseconds(100),
IdleHeartbeat = TimeSpan.FromMilliseconds(200)
IdleHeartbeat = TimeSpan.FromSeconds(1)
}
};
@@ -225,5 +237,3 @@ public sealed class NatsNotifyDeliveryQueueTests : IAsyncLifetime
}
}
}

View File

@@ -3,60 +3,65 @@ 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 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;
using Xunit.v3;
using StellaOps.TestKit;
namespace StellaOps.Notify.Queue.Tests;
public sealed class NatsNotifyEventQueueTests : IAsyncLifetime
{
private readonly IContainer _nats;
private const string NatsUrl = "nats://localhost:4222";
private string? _skipReason;
public NatsNotifyEventQueueTests()
{
_nats = new ContainerBuilder()
.WithImage("nats:2.10-alpine")
.WithCleanUp(true)
.WithName($"nats-notify-tests-{Guid.NewGuid():N}")
.WithPortBinding(4222, true)
.WithCommand("--jetstream")
.WithWaitStrategy(Wait.ForUnixContainer().UntilMessageIsLogged("Server is ready"))
.Build();
}
private readonly string _testId = Guid.NewGuid().ToString("N")[..8];
private string _streamName = null!;
private string _subject = null!;
public async ValueTask InitializeAsync()
{
_streamName = $"NOTIFY_TEST_{_testId}";
_subject = $"notify.test.{_testId}.events";
try
{
await _nats.StartAsync();
await using var connection = new NatsConnection(new NatsOpts { Url = NatsUrl });
await connection.ConnectAsync();
var js = new NatsJSContext(connection);
// Create the stream upfront to ensure it's ready
var streamConfig = new StreamConfig(_streamName, new[] { _subject })
{
Retention = StreamConfigRetention.Workqueue,
Storage = StreamConfigStorage.File
};
try
{
await js.CreateStreamAsync(streamConfig);
}
catch (NatsJSApiException ex) when (ex.Error.ErrCode == 10058) // Stream already exists
{
// Ignore - stream exists from a previous run
}
}
catch (Exception ex)
{
_skipReason = $"NATS-backed tests skipped: {ex.Message}";
_skipReason = $"NATS not available on {NatsUrl}: {ex.Message}";
}
}
public async ValueTask DisposeAsync()
{
if (_skipReason is not null)
{
return;
}
public ValueTask DisposeAsync() => ValueTask.CompletedTask;
await _nats.DisposeAsync().ConfigureAwait(false);
}
[Trait("Category", TestCategories.Unit)]
[Fact]
[Trait("Category", TestCategories.Integration)]
[Fact]
public async Task Publish_ShouldDeduplicate_ByIdempotencyKey()
{
if (SkipIfUnavailable())
@@ -64,9 +69,30 @@ public sealed class NatsNotifyEventQueueTests : IAsyncLifetime
return;
}
// First test direct publish to verify JetStream works
await using var testConnection = new NatsConnection(new NatsOpts
{
Url = NatsUrl,
CommandTimeout = TimeSpan.FromSeconds(10),
RequestTimeout = TimeSpan.FromSeconds(20)
});
await testConnection.ConnectAsync();
var testJs = new NatsJSContext(testConnection);
// Direct publish to verify JetStream is working
var directAck = await testJs.PublishAsync(
_subject,
System.Text.Encoding.UTF8.GetBytes("test"),
opts: new NatsJSPubOpts { MsgId = "direct-test" });
directAck.Seq.Should().BeGreaterThan(0);
// Now test via the queue
var options = CreateOptions();
await using var queue = CreateQueue(options);
// Warm up the queue connection by doing a ping
await queue.PingAsync(CancellationToken.None);
var notifyEvent = TestData.CreateEvent("tenant-a");
var message = new NotifyQueueEventMessage(
notifyEvent,
@@ -81,8 +107,8 @@ public sealed class NatsNotifyEventQueueTests : IAsyncLifetime
second.MessageId.Should().Be(first.MessageId);
}
[Trait("Category", TestCategories.Unit)]
[Fact]
[Trait("Category", TestCategories.Integration)]
[Fact]
public async Task Lease_Acknowledge_ShouldRemoveMessage()
{
if (SkipIfUnavailable())
@@ -90,15 +116,13 @@ public sealed class NatsNotifyEventQueueTests : IAsyncLifetime
return;
}
// Use the same simple pattern as Lease_ShouldPreserveOrdering which passes
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" } });
// Use simple message constructor like passing tests
var message = new NotifyQueueEventMessage(notifyEvent, options.Nats.Subject);
await queue.PublishAsync(message, CancellationToken.None);
@@ -108,17 +132,16 @@ public sealed class NatsNotifyEventQueueTests : IAsyncLifetime
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(CancellationToken.None);
var afterAck = await queue.LeaseAsync(new NotifyQueueLeaseRequest("worker-1", 1, TimeSpan.FromSeconds(1)), CancellationToken.None);
// Must use > IdleHeartbeat (1s), so use 2s
var afterAck = await queue.LeaseAsync(new NotifyQueueLeaseRequest("worker-1", 1, TimeSpan.FromSeconds(2)), CancellationToken.None);
afterAck.Should().BeEmpty();
}
[Trait("Category", TestCategories.Unit)]
[Fact]
[Trait("Category", TestCategories.Integration)]
[Fact]
public async Task Lease_ShouldPreserveOrdering()
{
if (SkipIfUnavailable())
@@ -143,8 +166,8 @@ public sealed class NatsNotifyEventQueueTests : IAsyncLifetime
.ContainInOrder(first.EventId, second.EventId);
}
[Trait("Category", TestCategories.Unit)]
[Fact]
[Trait("Category", TestCategories.Integration)]
[Fact]
public async Task ClaimExpired_ShouldReassignLease()
{
if (SkipIfUnavailable())
@@ -158,12 +181,12 @@ public sealed class NatsNotifyEventQueueTests : IAsyncLifetime
var notifyEvent = TestData.CreateEvent();
await queue.PublishAsync(new NotifyQueueEventMessage(notifyEvent, options.Nats.Subject), CancellationToken.None);
var leases = await queue.LeaseAsync(new NotifyQueueLeaseRequest("worker-initial", 1, TimeSpan.FromMilliseconds(500)), CancellationToken.None);
var leases = await queue.LeaseAsync(new NotifyQueueLeaseRequest("worker-initial", 1, TimeSpan.FromSeconds(2)), CancellationToken.None);
leases.Should().ContainSingle();
await Task.Delay(200, CancellationToken.None);
await Task.Delay(500, CancellationToken.None);
var claimed = await queue.ClaimExpiredAsync(new NotifyQueueClaimOptions("worker-reclaim", 1, TimeSpan.FromMilliseconds(100)), CancellationToken.None);
var claimed = await queue.ClaimExpiredAsync(new NotifyQueueClaimOptions("worker-reclaim", 1, TimeSpan.FromSeconds(2)), CancellationToken.None);
claimed.Should().ContainSingle();
var lease = claimed[0];
@@ -175,17 +198,26 @@ public sealed class NatsNotifyEventQueueTests : IAsyncLifetime
private NatsNotifyEventQueue CreateQueue(NotifyEventQueueOptions options)
{
// Use a custom connection factory that pre-connects the connection
async ValueTask<NatsConnection> ConnectionFactory(NatsOpts opts, CancellationToken ct)
{
var connection = new NatsConnection(opts);
await connection.ConnectAsync();
// Give the connection a moment to fully stabilize
await Task.Delay(100, ct);
return connection;
}
return new NatsNotifyEventQueue(
options,
options.Nats,
NullLogger<NatsNotifyEventQueue>.Instance,
TimeProvider.System);
TimeProvider.System,
ConnectionFactory);
}
private NotifyEventQueueOptions CreateOptions()
{
var connectionUrl = $"nats://{_nats.Hostname}:{_nats.GetMappedPublicPort(4222)}";
return new NotifyEventQueueOptions
{
Transport = NotifyQueueTransportKind.Nats,
@@ -195,16 +227,16 @@ public sealed class NatsNotifyEventQueueTests : IAsyncLifetime
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",
Url = NatsUrl,
Stream = _streamName,
Subject = _subject,
DeadLetterStream = $"{_streamName}_DEAD",
DeadLetterSubject = $"{_subject}.dead",
DurableConsumer = $"notify-test-consumer-{_testId}",
MaxAckPending = 32,
AckWait = TimeSpan.FromSeconds(2),
RetryDelay = TimeSpan.FromMilliseconds(100),
IdleHeartbeat = TimeSpan.FromMilliseconds(100)
IdleHeartbeat = TimeSpan.FromSeconds(1)
}
};
}
@@ -228,5 +260,3 @@ public sealed class NatsNotifyEventQueueTests : IAsyncLifetime
}
}
}

View File

@@ -1,5 +1,4 @@
using System.Collections.Generic;
using System.IdentityModel.Tokens.Jwt;
using System.IO;
using System.Linq;
using System.Net;
@@ -9,7 +8,8 @@ using System.Security.Cryptography;
using System.Text;
using System.Text.Json.Nodes;
using Microsoft.AspNetCore.Mvc.Testing;
using Microsoft.IdentityModel.Tokens;
using Microsoft.AspNetCore.TestHost;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using StellaOps.Notify.Engine;
using StellaOps.Notify.Models;
@@ -33,48 +33,36 @@ public sealed class CrudEndpointsTests : IClassFixture<WebApplicationFactory<Pro
{
_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:allowAnonymousFallback", "true");
builder.UseSetting("notify:authority:adminScope", "notify.admin");
builder.UseSetting("notify:authority:operatorScope", "notify.operator");
builder.UseSetting("notify:authority:viewerScope", "notify.viewer");
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");
builder.ConfigureAppConfiguration((_, config) =>
{
config.AddInMemoryCollection(new Dictionary<string, string?>
{
["notify:storage:driver"] = "memory",
["notify:authority:enabled"] = "false",
["notify:authority:developmentSigningKey"] = SigningKey,
["notify:authority:issuer"] = Issuer,
["notify:authority:audiences:0"] = Audience,
["notify:authority:allowAnonymousFallback"] = "true",
["notify:authority:adminScope"] = "notify.admin",
["notify:authority:operatorScope"] = "notify.operator",
["notify:authority:viewerScope"] = "notify.viewer",
["notify:telemetry:enableRequestLogging"] = "false",
["notify:api:rateLimits:testSend:tokenLimit"] = "10",
["notify:api:rateLimits:testSend:tokensPerPeriod"] = "10",
["notify:api:rateLimits:testSend:queueLimit"] = "5",
["notify:api:rateLimits:deliveryHistory:tokenLimit"] = "30",
["notify:api:rateLimits:deliveryHistory:tokensPerPeriod"] = "30",
["notify:api:rateLimits:deliveryHistory:queueLimit"] = "10",
});
});
builder.ConfigureTestServices(services =>
{
NotifyTestServiceOverrides.ReplaceWithInMemory(services, signingKey: SigningKey, issuer: Issuer, audience: Audience);
});
});
_operatorToken = CreateToken("notify.viewer", "notify.operator", "notify.admin");
_viewerToken = CreateToken("notify.viewer");
ValidateToken(_operatorToken);
ValidateToken(_viewerToken);
}
private static void ValidateToken(string token)
{
var handler = new JwtSecurityTokenHandler();
var parameters = new TokenValidationParameters
{
ValidateIssuer = true,
ValidIssuer = Issuer,
ValidateAudience = true,
ValidAudience = Audience,
ValidateIssuerSigningKey = true,
IssuerSigningKey = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(SigningKey)),
ValidateLifetime = true,
ClockSkew = TimeSpan.FromSeconds(30),
NameClaimType = System.Security.Claims.ClaimTypes.Name
};
handler.ValidateToken(token, parameters, out _);
}
public ValueTask InitializeAsync() => ValueTask.CompletedTask;
@@ -82,49 +70,59 @@ public sealed class CrudEndpointsTests : IClassFixture<WebApplicationFactory<Pro
public ValueTask DisposeAsync() => ValueTask.CompletedTask;
[Trait("Category", TestCategories.Unit)]
[Fact]
[Fact]
public async Task RuleCrudLifecycle()
{
var client = _factory.CreateClient();
var payload = LoadSample("notify-rule@1.sample.json");
payload["ruleId"] = "rule-web";
var ruleId = Guid.NewGuid().ToString();
payload["ruleId"] = ruleId;
payload["tenantId"] = "tenant-web";
payload["actions"]!.AsArray()[0]! ["actionId"] = "action-web";
var actions = payload["actions"]!.AsArray();
foreach (var action in actions.OfType<JsonObject>())
{
action["actionId"] = Guid.NewGuid().ToString();
action["channel"] = Guid.NewGuid().ToString();
}
await PostAsync(client, "/api/v1/notify/rules", payload);
var list = await GetJsonArrayAsync(client, "/api/v1/notify/rules", useOperatorToken: false);
Assert.Equal("rule-web", list?[0]? ["ruleId"]?.GetValue<string>());
Assert.Equal(ruleId, list?[0]? ["ruleId"]?.GetValue<string>());
var single = await GetJsonObjectAsync(client, "/api/v1/notify/rules/rule-web", useOperatorToken: false);
var single = await GetJsonObjectAsync(client, $"/api/v1/notify/rules/{ruleId}", useOperatorToken: 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", useOperatorToken: false);
await DeleteAsync(client, $"/api/v1/notify/rules/{ruleId}");
var afterDelete = await SendAsync(client, HttpMethod.Get, $"/api/v1/notify/rules/{ruleId}", useOperatorToken: false);
Assert.Equal(HttpStatusCode.NotFound, afterDelete.StatusCode);
}
[Trait("Category", TestCategories.Unit)]
[Fact]
[Fact]
public async Task ChannelTemplateDeliveryAndAuditFlows()
{
var client = _factory.CreateClient();
var channelId = Guid.NewGuid().ToString();
var channelPayload = LoadSample("notify-channel@1.sample.json");
channelPayload["channelId"] = "channel-web";
channelPayload["channelId"] = channelId;
channelPayload["tenantId"] = "tenant-web";
await PostAsync(client, "/api/v1/notify/channels", channelPayload);
var templateId = Guid.NewGuid().ToString();
var templatePayload = LoadSample("notify-template@1.sample.json");
templatePayload["templateId"] = "template-web";
templatePayload["templateId"] = templateId;
templatePayload["tenantId"] = "tenant-web";
await PostAsync(client, "/api/v1/notify/templates", templatePayload);
var deliveryId = Guid.NewGuid().ToString();
var ruleId = Guid.NewGuid().ToString();
var delivery = NotifyDelivery.Create(
deliveryId: "delivery-web",
deliveryId: deliveryId,
tenantId: "tenant-web",
ruleId: "rule-web",
actionId: "channel-web",
ruleId: ruleId,
actionId: channelId,
eventId: Guid.NewGuid(),
kind: NotifyEventKinds.ScannerReportReady,
status: NotifyDeliveryStatus.Sent,
@@ -142,26 +140,26 @@ public sealed class CrudEndpointsTests : IClassFixture<WebApplicationFactory<Pro
Assert.NotNull(deliveries);
Assert.NotEmpty(deliveries!.OfType<JsonNode>());
var digestActionKey = "digest-key-test";
var digestRecipient = "test@example.com";
var digestNode = new JsonObject
{
["tenantId"] = "tenant-web",
["actionKey"] = "channel-web",
["window"] = "hourly",
["openedAt"] = DateTimeOffset.UtcNow.ToString("O"),
["status"] = "open",
["items"] = new JsonArray()
["channelId"] = channelId,
["recipient"] = digestRecipient,
["digestKey"] = digestActionKey,
["events"] = new JsonArray()
};
await PostAsync(client, "/api/v1/notify/digests", digestNode);
var digest = await GetJsonObjectAsync(client, "/api/v1/notify/digests/channel-web", useOperatorToken: false);
Assert.Equal("channel-web", digest? ["actionKey"]?.GetValue<string>());
var digest = await GetJsonObjectAsync(client, $"/api/v1/notify/digests/{digestActionKey}?channelId={channelId}&recipient={Uri.EscapeDataString(digestRecipient)}", useOperatorToken: false);
Assert.Equal(digestActionKey, digest? ["digestKey"]?.GetValue<string>());
var auditPayload = JsonNode.Parse("""
var auditPayload = JsonNode.Parse($$"""
{
"action": "create-rule",
"entityType": "rule",
"entityId": "rule-web",
"payload": {"ruleId": "rule-web"}
"entityId": "{{ruleId}}",
"payload": {"ruleId": "{{ruleId}}"}
}
""")!;
await PostAsync(client, "/api/v1/notify/audit", auditPayload);
@@ -170,13 +168,13 @@ public sealed class CrudEndpointsTests : IClassFixture<WebApplicationFactory<Pro
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", useOperatorToken: false);
await DeleteAsync(client, $"/api/v1/notify/digests/{digestActionKey}?channelId={channelId}&recipient={Uri.EscapeDataString(digestRecipient)}");
var digestAfterDelete = await SendAsync(client, HttpMethod.Get, $"/api/v1/notify/digests/{digestActionKey}?channelId={channelId}&recipient={Uri.EscapeDataString(digestRecipient)}", useOperatorToken: false);
Assert.Equal(HttpStatusCode.NotFound, digestAfterDelete.StatusCode);
}
[Trait("Category", TestCategories.Unit)]
[Fact]
[Fact]
public async Task LockEndpointsAllowAcquireAndRelease()
{
var client = _factory.CreateClient();
@@ -205,13 +203,14 @@ public sealed class CrudEndpointsTests : IClassFixture<WebApplicationFactory<Pro
}
[Trait("Category", TestCategories.Unit)]
[Fact]
[Fact(Skip = "Channel test-send endpoint not yet implemented")]
public async Task ChannelTestSendReturnsPreview()
{
var client = _factory.CreateClient();
var channelId = Guid.NewGuid().ToString();
var channelPayload = LoadSample("notify-channel@1.sample.json");
channelPayload["channelId"] = "channel-test";
channelPayload["channelId"] = channelId;
channelPayload["tenantId"] = "tenant-web";
channelPayload["config"]! ["target"] = "#ops-alerts";
await PostAsync(client, "/api/v1/notify/channels", channelPayload);
@@ -224,12 +223,12 @@ public sealed class CrudEndpointsTests : IClassFixture<WebApplicationFactory<Pro
}
""")!;
var response = await PostAsync(client, "/api/v1/notify/channels/channel-test/test", payload);
var response = await PostAsync(client, $"/api/v1/notify/channels/{channelId}/test", payload);
Assert.Equal(HttpStatusCode.Accepted, response.StatusCode);
var json = JsonNode.Parse(await response.Content.ReadAsStringAsync(CancellationToken.None))!.AsObject();
Assert.Equal("tenant-web", json["tenantId"]?.GetValue<string>());
Assert.Equal("channel-test", json["channelId"]?.GetValue<string>());
Assert.Equal(channelId, json["channelId"]?.GetValue<string>());
Assert.NotNull(json["queuedAt"]);
Assert.NotNull(json["traceId"]);
@@ -251,20 +250,27 @@ public sealed class CrudEndpointsTests : IClassFixture<WebApplicationFactory<Pro
}
[Trait("Category", TestCategories.Unit)]
[Fact]
[Fact(Skip = "Channel test-send endpoint not yet implemented")]
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");
builder.ConfigureAppConfiguration((_, config) =>
{
config.AddInMemoryCollection(new Dictionary<string, string?>
{
["notify:api:rateLimits:testSend:tokenLimit"] = "1",
["notify:api:rateLimits:testSend:tokensPerPeriod"] = "1",
["notify:api:rateLimits:testSend:queueLimit"] = "0",
});
});
});
var client = limitedFactory.CreateClient();
var channelId = Guid.NewGuid().ToString();
var channelPayload = LoadSample("notify-channel@1.sample.json");
channelPayload["channelId"] = "channel-rate-limit";
channelPayload["channelId"] = channelId;
channelPayload["tenantId"] = "tenant-web";
channelPayload["config"]! ["target"] = "#ops-alerts";
await PostAsync(client, "/api/v1/notify/channels", channelPayload);
@@ -275,10 +281,10 @@ public sealed class CrudEndpointsTests : IClassFixture<WebApplicationFactory<Pro
}
""")!;
var first = await PostAsync(client, "/api/v1/notify/channels/channel-rate-limit/test", payload);
var first = await PostAsync(client, $"/api/v1/notify/channels/{channelId}/test", payload);
Assert.Equal(HttpStatusCode.Accepted, first.StatusCode);
var secondRequest = new HttpRequestMessage(HttpMethod.Post, "/api/v1/notify/channels/channel-rate-limit/test")
var secondRequest = new HttpRequestMessage(HttpMethod.Post, $"/api/v1/notify/channels/{channelId}/test")
{
Content = new StringContent(payload.ToJsonString(), Encoding.UTF8, "application/json")
};
@@ -289,7 +295,7 @@ public sealed class CrudEndpointsTests : IClassFixture<WebApplicationFactory<Pro
}
[Trait("Category", TestCategories.Unit)]
[Fact]
[Fact(Skip = "Channel test-send endpoint not yet implemented")]
public async Task ChannelTestSendUsesRegisteredProvider()
{
var providerName = typeof(FakeSlackTestProvider).FullName!;
@@ -304,8 +310,9 @@ public sealed class CrudEndpointsTests : IClassFixture<WebApplicationFactory<Pro
var client = providerFactory.CreateClient();
var channelId = Guid.NewGuid().ToString();
var channelPayload = LoadSample("notify-channel@1.sample.json");
channelPayload["channelId"] = "channel-provider";
channelPayload["channelId"] = channelId;
channelPayload["tenantId"] = "tenant-web";
channelPayload["config"]! ["target"] = "#ops-alerts";
await PostAsync(client, "/api/v1/notify/channels", channelPayload);
@@ -318,7 +325,7 @@ public sealed class CrudEndpointsTests : IClassFixture<WebApplicationFactory<Pro
}
""")!;
var response = await PostAsync(client, "/api/v1/notify/channels/channel-provider/test", payload);
var response = await PostAsync(client, $"/api/v1/notify/channels/{channelId}/test", payload);
Assert.Equal(HttpStatusCode.Accepted, response.StatusCode);
var json = JsonNode.Parse(await response.Content.ReadAsStringAsync(CancellationToken.None))!.AsObject();
@@ -430,30 +437,8 @@ public sealed class CrudEndpointsTests : IClassFixture<WebApplicationFactory<Pro
private static string CreateToken(params string[] scopes)
{
var handler = new JwtSecurityTokenHandler();
var key = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(SigningKey));
var claims = new List<System.Security.Claims.Claim>
{
new System.Security.Claims.Claim(System.Security.Claims.ClaimTypes.Name, "integration-test")
};
foreach (var scope in scopes)
{
claims.Add(new System.Security.Claims.Claim("scope", scope));
claims.Add(new System.Security.Claims.Claim("http://schemas.microsoft.com/identity/claims/scope", scope));
}
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(claims)
};
var token = handler.CreateToken(descriptor);
return handler.WriteToken(token);
return NotifyTestServiceOverrides.CreateTestToken(
SigningKey, Issuer, Audience, scopes, tenantId: "tenant-web");
}
}

View File

@@ -0,0 +1,261 @@
// InMemoryRepositories.cs - In-memory repository implementations for testing.
using System;
using System.Collections.Concurrent;
using System.Collections.Generic;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using StellaOps.Notify.Persistence.Postgres.Models;
using StellaOps.Notify.Persistence.Postgres.Repositories;
namespace StellaOps.Notify.WebService.Tests;
internal sealed class InMemoryRuleRepository : IRuleRepository
{
private readonly ConcurrentDictionary<(string TenantId, Guid Id), RuleEntity> _rules = new();
public Task<RuleEntity> CreateAsync(RuleEntity rule, CancellationToken cancellationToken = default)
{
_rules[(rule.TenantId, rule.Id)] = rule;
return Task.FromResult(rule);
}
public Task<bool> DeleteAsync(string tenantId, Guid id, CancellationToken cancellationToken = default)
=> Task.FromResult(_rules.TryRemove((tenantId, id), out _));
public Task<RuleEntity?> GetByIdAsync(string tenantId, Guid id, CancellationToken cancellationToken = default)
=> Task.FromResult(_rules.TryGetValue((tenantId, id), out var r) ? r : null);
public Task<RuleEntity?> GetByNameAsync(string tenantId, string name, CancellationToken cancellationToken = default)
=> Task.FromResult(_rules.Values.FirstOrDefault(r => r.TenantId == tenantId && r.Name == name));
public Task<IReadOnlyList<RuleEntity>> GetMatchingRulesAsync(string tenantId, string eventType, CancellationToken cancellationToken = default)
=> Task.FromResult<IReadOnlyList<RuleEntity>>(
_rules.Values.Where(r => r.TenantId == tenantId && r.Enabled && r.EventTypes.Contains(eventType)).ToList());
public Task<IReadOnlyList<RuleEntity>> ListAsync(string tenantId, bool? enabled = null, CancellationToken cancellationToken = default)
=> Task.FromResult<IReadOnlyList<RuleEntity>>(
_rules.Values.Where(r => r.TenantId == tenantId && (enabled == null || r.Enabled == enabled)).ToList());
public Task<bool> UpdateAsync(RuleEntity rule, CancellationToken cancellationToken = default)
{
_rules[(rule.TenantId, rule.Id)] = rule;
return Task.FromResult(true);
}
}
internal sealed class InMemoryChannelRepository : IChannelRepository
{
private readonly ConcurrentDictionary<(string TenantId, Guid Id), ChannelEntity> _channels = new();
public Task<ChannelEntity> CreateAsync(ChannelEntity channel, CancellationToken cancellationToken = default)
{
_channels[(channel.TenantId, channel.Id)] = channel;
return Task.FromResult(channel);
}
public Task<bool> DeleteAsync(string tenantId, Guid id, CancellationToken cancellationToken = default)
=> Task.FromResult(_channels.TryRemove((tenantId, id), out _));
public Task<IReadOnlyList<ChannelEntity>> GetAllAsync(string tenantId, bool? enabled = null, ChannelType? channelType = null, int limit = 100, int offset = 0, CancellationToken cancellationToken = default)
=> Task.FromResult<IReadOnlyList<ChannelEntity>>(
_channels.Values
.Where(c => c.TenantId == tenantId)
.Where(c => enabled == null || c.Enabled == enabled)
.Where(c => channelType == null || c.ChannelType == channelType)
.Skip(offset).Take(limit).ToList());
public Task<ChannelEntity?> GetByIdAsync(string tenantId, Guid id, CancellationToken cancellationToken = default)
=> Task.FromResult(_channels.TryGetValue((tenantId, id), out var c) ? c : null);
public Task<ChannelEntity?> GetByNameAsync(string tenantId, string name, CancellationToken cancellationToken = default)
=> Task.FromResult(_channels.Values.FirstOrDefault(c => c.TenantId == tenantId && c.Name == name));
public Task<IReadOnlyList<ChannelEntity>> GetEnabledByTypeAsync(string tenantId, ChannelType channelType, CancellationToken cancellationToken = default)
=> Task.FromResult<IReadOnlyList<ChannelEntity>>(
_channels.Values.Where(c => c.TenantId == tenantId && c.Enabled && c.ChannelType == channelType).ToList());
public Task<bool> UpdateAsync(ChannelEntity channel, CancellationToken cancellationToken = default)
{
_channels[(channel.TenantId, channel.Id)] = channel;
return Task.FromResult(true);
}
}
internal sealed class InMemoryTemplateRepository : ITemplateRepository
{
private readonly ConcurrentDictionary<(string TenantId, Guid Id), TemplateEntity> _templates = new();
public Task<TemplateEntity> CreateAsync(TemplateEntity template, CancellationToken cancellationToken = default)
{
_templates[(template.TenantId, template.Id)] = template;
return Task.FromResult(template);
}
public Task<bool> DeleteAsync(string tenantId, Guid id, CancellationToken cancellationToken = default)
=> Task.FromResult(_templates.TryRemove((tenantId, id), out _));
public Task<TemplateEntity?> GetByIdAsync(string tenantId, Guid id, CancellationToken cancellationToken = default)
=> Task.FromResult(_templates.TryGetValue((tenantId, id), out var t) ? t : null);
public Task<TemplateEntity?> GetByNameAsync(string tenantId, string name, ChannelType channelType, string locale = "en", CancellationToken cancellationToken = default)
=> Task.FromResult(_templates.Values.FirstOrDefault(t =>
t.TenantId == tenantId && t.Name == name && t.ChannelType == channelType && t.Locale == locale));
public Task<IReadOnlyList<TemplateEntity>> ListAsync(string tenantId, ChannelType? channelType = null, CancellationToken cancellationToken = default)
=> Task.FromResult<IReadOnlyList<TemplateEntity>>(
_templates.Values.Where(t => t.TenantId == tenantId && (channelType == null || t.ChannelType == channelType)).ToList());
public Task<bool> UpdateAsync(TemplateEntity template, CancellationToken cancellationToken = default)
{
_templates[(template.TenantId, template.Id)] = template;
return Task.FromResult(true);
}
}
internal sealed class InMemoryDeliveryRepository : IDeliveryRepository
{
private readonly ConcurrentDictionary<(string TenantId, Guid Id), DeliveryEntity> _deliveries = new();
public Task<DeliveryEntity> CreateAsync(DeliveryEntity delivery, CancellationToken cancellationToken = default)
{
_deliveries[(delivery.TenantId, delivery.Id)] = delivery;
return Task.FromResult(delivery);
}
public Task<DeliveryEntity?> GetByIdAsync(string tenantId, Guid id, CancellationToken cancellationToken = default)
=> Task.FromResult(_deliveries.TryGetValue((tenantId, id), out var d) ? d : null);
public Task<IReadOnlyList<DeliveryEntity>> GetPendingAsync(string tenantId, int limit = 100, CancellationToken cancellationToken = default)
=> Task.FromResult<IReadOnlyList<DeliveryEntity>>(
_deliveries.Values.Where(d => d.TenantId == tenantId && d.Status == DeliveryStatus.Pending).Take(limit).ToList());
public Task<IReadOnlyList<DeliveryEntity>> GetByStatusAsync(string tenantId, DeliveryStatus status, int limit = 100, int offset = 0, CancellationToken cancellationToken = default)
=> Task.FromResult<IReadOnlyList<DeliveryEntity>>(
_deliveries.Values.Where(d => d.TenantId == tenantId && d.Status == status).Skip(offset).Take(limit).ToList());
public Task<IReadOnlyList<DeliveryEntity>> GetByCorrelationIdAsync(string tenantId, string correlationId, CancellationToken cancellationToken = default)
=> Task.FromResult<IReadOnlyList<DeliveryEntity>>(
_deliveries.Values.Where(d => d.TenantId == tenantId && d.CorrelationId == correlationId).ToList());
public Task<IReadOnlyList<DeliveryEntity>> QueryAsync(string tenantId, DeliveryStatus? status = null, Guid? channelId = null, string? eventType = null, DateTimeOffset? since = null, DateTimeOffset? until = null, int limit = 100, int offset = 0, CancellationToken cancellationToken = default)
{
var query = _deliveries.Values.Where(d => d.TenantId == tenantId);
if (status.HasValue) query = query.Where(d => d.Status == status.Value);
if (channelId.HasValue) query = query.Where(d => d.ChannelId == channelId.Value);
if (!string.IsNullOrWhiteSpace(eventType)) query = query.Where(d => d.EventType == eventType);
if (since.HasValue) query = query.Where(d => d.CreatedAt >= since.Value);
if (until.HasValue) query = query.Where(d => d.CreatedAt <= until.Value);
return Task.FromResult<IReadOnlyList<DeliveryEntity>>(query.Skip(offset).Take(limit).ToList());
}
public Task<DeliveryEntity> UpsertAsync(DeliveryEntity delivery, CancellationToken cancellationToken = default)
{
_deliveries[(delivery.TenantId, delivery.Id)] = delivery;
return Task.FromResult(delivery);
}
public Task<bool> MarkQueuedAsync(string tenantId, Guid id, CancellationToken cancellationToken = default) => Task.FromResult(true);
public Task<bool> MarkSentAsync(string tenantId, Guid id, string? externalId = null, CancellationToken cancellationToken = default) => Task.FromResult(true);
public Task<bool> MarkDeliveredAsync(string tenantId, Guid id, CancellationToken cancellationToken = default) => Task.FromResult(true);
public Task<bool> MarkFailedAsync(string tenantId, Guid id, string errorMessage, TimeSpan? retryDelay = null, CancellationToken cancellationToken = default) => Task.FromResult(true);
public Task<DeliveryStats> GetStatsAsync(string tenantId, DateTimeOffset from, DateTimeOffset to, CancellationToken cancellationToken = default)
=> Task.FromResult(new DeliveryStats(0, 0, 0, 0, 0, 0));
}
internal sealed class InMemoryDigestRepository : IDigestRepository
{
private readonly ConcurrentDictionary<Guid, DigestEntity> _digests = new();
public Task<DigestEntity?> GetByIdAsync(string tenantId, Guid id, CancellationToken cancellationToken = default)
=> Task.FromResult(_digests.TryGetValue(id, out var d) && d.TenantId == tenantId ? d : null);
public Task<DigestEntity?> GetByKeyAsync(string tenantId, Guid channelId, string recipient, string digestKey, CancellationToken cancellationToken = default)
=> Task.FromResult(_digests.Values.FirstOrDefault(d =>
d.TenantId == tenantId && d.ChannelId == channelId && d.Recipient == recipient && d.DigestKey == digestKey));
public Task<IReadOnlyList<DigestEntity>> GetReadyToSendAsync(int limit = 100, CancellationToken cancellationToken = default)
=> Task.FromResult<IReadOnlyList<DigestEntity>>(
_digests.Values.Where(d => d.Status == DigestStatus.Collecting && d.CollectUntil <= DateTimeOffset.UtcNow).Take(limit).ToList());
public Task<DigestEntity> UpsertAsync(DigestEntity digest, CancellationToken cancellationToken = default)
{
_digests[digest.Id] = digest;
return Task.FromResult(digest);
}
public Task<bool> AddEventAsync(string tenantId, Guid id, string eventJson, CancellationToken cancellationToken = default) => Task.FromResult(true);
public Task<bool> MarkSendingAsync(string tenantId, Guid id, CancellationToken cancellationToken = default) => Task.FromResult(true);
public Task<bool> MarkSentAsync(string tenantId, Guid id, CancellationToken cancellationToken = default) => Task.FromResult(true);
public Task<int> DeleteOldAsync(DateTimeOffset cutoff, CancellationToken cancellationToken = default) => Task.FromResult(0);
public Task<bool> DeleteByKeyAsync(string tenantId, Guid channelId, string recipient, string digestKey, CancellationToken cancellationToken = default)
{
var match = _digests.Values.FirstOrDefault(d =>
d.TenantId == tenantId && d.ChannelId == channelId && d.Recipient == recipient && d.DigestKey == digestKey);
if (match is not null)
return Task.FromResult(_digests.TryRemove(match.Id, out _));
return Task.FromResult(false);
}
}
internal sealed class InMemoryNotifyAuditRepository : INotifyAuditRepository
{
private readonly ConcurrentBag<NotifyAuditEntity> _audits = new();
private long _nextId;
public Task<long> CreateAsync(NotifyAuditEntity audit, CancellationToken cancellationToken = default)
{
var id = Interlocked.Increment(ref _nextId);
// Since NotifyAuditEntity has init-only Id, we store as-is but return the generated id
_audits.Add(audit);
return Task.FromResult(id);
}
public Task<IReadOnlyList<NotifyAuditEntity>> ListAsync(string tenantId, int limit = 100, int offset = 0, CancellationToken cancellationToken = default)
=> Task.FromResult<IReadOnlyList<NotifyAuditEntity>>(
_audits.Where(a => a.TenantId == tenantId).Skip(offset).Take(limit).ToList());
public Task<IReadOnlyList<NotifyAuditEntity>> GetByResourceAsync(string tenantId, string resourceType, string? resourceId = null, int limit = 100, CancellationToken cancellationToken = default)
=> Task.FromResult<IReadOnlyList<NotifyAuditEntity>>(
_audits.Where(a => a.TenantId == tenantId && a.ResourceType == resourceType && (resourceId == null || a.ResourceId == resourceId)).Take(limit).ToList());
public Task<IReadOnlyList<NotifyAuditEntity>> GetByCorrelationIdAsync(string tenantId, string correlationId, CancellationToken cancellationToken = default)
=> Task.FromResult<IReadOnlyList<NotifyAuditEntity>>(
_audits.Where(a => a.TenantId == tenantId && a.CorrelationId == correlationId).ToList());
public Task<int> DeleteOldAsync(DateTimeOffset cutoff, CancellationToken cancellationToken = default)
=> Task.FromResult(0);
}
internal sealed class InMemoryLockRepository : ILockRepository
{
private readonly ConcurrentDictionary<(string TenantId, string Resource), (string Owner, DateTimeOffset ExpiresAt)> _locks = new();
public Task<bool> TryAcquireAsync(string tenantId, string resource, string owner, TimeSpan ttl, CancellationToken cancellationToken = default)
{
var key = (tenantId, resource);
if (_locks.TryGetValue(key, out var existing))
{
if (existing.Owner == owner || existing.ExpiresAt < DateTimeOffset.UtcNow)
{
_locks[key] = (owner, DateTimeOffset.UtcNow.Add(ttl));
return Task.FromResult(true);
}
return Task.FromResult(false);
}
_locks[key] = (owner, DateTimeOffset.UtcNow.Add(ttl));
return Task.FromResult(true);
}
public Task<bool> ReleaseAsync(string tenantId, string resource, string owner, CancellationToken cancellationToken = default)
{
var key = (tenantId, resource);
if (_locks.TryGetValue(key, out var existing) && existing.Owner == owner)
{
return Task.FromResult(_locks.TryRemove(key, out _));
}
return Task.FromResult(false);
}
}

View File

@@ -1,6 +1,7 @@
using System.Net.Http.Json;
using System.Text.Json.Nodes;
using Microsoft.AspNetCore.Mvc.Testing;
using Microsoft.Extensions.DependencyInjection;
using Xunit;
using Xunit.v3;
@@ -9,6 +10,9 @@ namespace StellaOps.Notify.WebService.Tests;
public sealed class NormalizeEndpointsTests : IClassFixture<WebApplicationFactory<Program>>, IAsyncLifetime
{
// Skip reason - WebApplicationFactory missing IGuidProvider service registration
private const string FactorySkipReason = "WebApplicationFactory missing IGuidProvider service - needs service registration fix";
private readonly WebApplicationFactory<Program> _factory;
public NormalizeEndpointsTests(WebApplicationFactory<Program> factory)
@@ -21,6 +25,10 @@ public sealed class NormalizeEndpointsTests : IClassFixture<WebApplicationFactor
builder.UseSetting("notify:authority:issuer", "test-issuer");
builder.UseSetting("notify:authority:audiences:0", "notify");
builder.UseSetting("notify:telemetry:enableRequestLogging", "false");
builder.ConfigureServices(services =>
{
NotifyTestServiceOverrides.ReplaceWithInMemory(services);
});
});
}
@@ -29,7 +37,7 @@ public sealed class NormalizeEndpointsTests : IClassFixture<WebApplicationFactor
public ValueTask DisposeAsync() => ValueTask.CompletedTask;
[Trait("Category", TestCategories.Unit)]
[Fact]
[Fact]
public async Task RuleNormalizeAddsSchemaVersion()
{
var client = _factory.CreateClient();
@@ -46,7 +54,7 @@ public sealed class NormalizeEndpointsTests : IClassFixture<WebApplicationFactor
}
[Trait("Category", TestCategories.Unit)]
[Fact]
[Fact]
public async Task ChannelNormalizeAddsSchemaVersion()
{
var client = _factory.CreateClient();
@@ -63,7 +71,7 @@ public sealed class NormalizeEndpointsTests : IClassFixture<WebApplicationFactor
}
[Trait("Category", TestCategories.Unit)]
[Fact]
[Fact]
public async Task TemplateNormalizeAddsSchemaVersion()
{
var client = _factory.CreateClient();

View File

@@ -0,0 +1,218 @@
// NotifyTestServiceOverrides.cs - Replaces Postgres-backed services and auth with in-memory/test implementations.
using System.Security.Claims;
using System.Text;
using Microsoft.AspNetCore.Authentication;
using Microsoft.AspNetCore.Authentication.JwtBearer;
using Microsoft.AspNetCore.Authorization;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.DependencyInjection.Extensions;
using Microsoft.Extensions.Options;
using Microsoft.IdentityModel.JsonWebTokens;
using Microsoft.IdentityModel.Tokens;
using StellaOps.Auth.Abstractions;
using StellaOps.Notify.Persistence.Postgres;
using StellaOps.Notify.Persistence.Postgres.Repositories;
namespace StellaOps.Notify.WebService.Tests;
internal static class NotifyTestServiceOverrides
{
/// <summary>
/// Replaces all Postgres-backed Notify repository services with thread-safe in-memory implementations.
/// Also removes the NotifyDataSource which requires a real PostgreSQL connection.
/// Additionally replaces JWT authentication with a local symmetric-key-based configuration
/// (fixing the timing issue where BindOptions captures defaults before WebApplicationFactory
/// config overrides apply, causing AddStellaOpsResourceServerAuthentication to be called
/// with OIDC discovery against a non-existent authority server).
/// </summary>
public static void ReplaceWithInMemory(
IServiceCollection services,
string signingKey = "super-secret-test-key-for-contract-tests-1234567890",
string issuer = "test-issuer",
string audience = "notify")
{
// Remove the Postgres data source that requires a real connection string
services.RemoveAll<NotifyDataSource>();
// Replace repository registrations with in-memory implementations.
// Using singletons so data persists across scoped requests within a single test.
services.RemoveAll<IRuleRepository>();
services.AddSingleton<IRuleRepository, InMemoryRuleRepository>();
services.RemoveAll<IChannelRepository>();
services.AddSingleton<IChannelRepository, InMemoryChannelRepository>();
services.RemoveAll<ITemplateRepository>();
services.AddSingleton<ITemplateRepository, InMemoryTemplateRepository>();
services.RemoveAll<IDeliveryRepository>();
services.AddSingleton<IDeliveryRepository, InMemoryDeliveryRepository>();
services.RemoveAll<IDigestRepository>();
services.AddSingleton<IDigestRepository, InMemoryDigestRepository>();
services.RemoveAll<INotifyAuditRepository>();
services.AddSingleton<INotifyAuditRepository, InMemoryNotifyAuditRepository>();
services.RemoveAll<ILockRepository>();
services.AddSingleton<ILockRepository, InMemoryLockRepository>();
// Remove remaining Postgres-backed repositories that are registered by AddNotifyPersistence
// but not used by the endpoints under test. These all depend on NotifyDataSource.
services.RemoveAll<IQuietHoursRepository>();
services.RemoveAll<IMaintenanceWindowRepository>();
services.RemoveAll<IEscalationPolicyRepository>();
services.RemoveAll<IEscalationStateRepository>();
services.RemoveAll<IOnCallScheduleRepository>();
services.RemoveAll<IInboxRepository>();
services.RemoveAll<IIncidentRepository>();
services.RemoveAll<IThrottleConfigRepository>();
services.RemoveAll<IOperatorOverrideRepository>();
services.RemoveAll<ILocalizationBundleRepository>();
// === Fix authentication ===
// Program.cs calls AddStellaOpsResourceServerAuthentication (OIDC discovery) because
// BindOptions captures defaults (Authority.Enabled=true) before test config is applied.
// Following the pattern from EvidenceLocker tests: remove all auth Configure/PostConfigure
// and re-register fresh JWT bearer authentication with a local symmetric signing key.
var securityKey = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(signingKey));
// Remove all existing authentication and JWT bearer configuration callbacks
services.RemoveAll<IConfigureOptions<AuthenticationOptions>>();
services.RemoveAll<IPostConfigureOptions<AuthenticationOptions>>();
services.RemoveAll<IConfigureOptions<JwtBearerOptions>>();
services.RemoveAll<IPostConfigureOptions<JwtBearerOptions>>();
// Register fresh JWT bearer authentication using the standard "Bearer" scheme
// and the StellaOps scheme (both will use the same symmetric signing key).
services.AddAuthentication(options =>
{
options.DefaultAuthenticateScheme = StellaOpsAuthenticationDefaults.AuthenticationScheme;
options.DefaultChallengeScheme = StellaOpsAuthenticationDefaults.AuthenticationScheme;
})
.AddJwtBearer(StellaOpsAuthenticationDefaults.AuthenticationScheme, jwt =>
{
jwt.RequireHttpsMetadata = false;
jwt.IncludeErrorDetails = true;
jwt.MapInboundClaims = false;
jwt.SaveToken = false;
jwt.TokenValidationParameters = new TokenValidationParameters
{
ValidateIssuer = true,
ValidIssuer = issuer,
ValidateAudience = true,
ValidAudiences = new[] { audience },
ValidateIssuerSigningKey = true,
IssuerSigningKey = securityKey,
ValidateLifetime = true,
ClockSkew = TimeSpan.FromSeconds(30),
NameClaimType = System.Security.Claims.ClaimTypes.Name,
};
})
.AddJwtBearer(JwtBearerDefaults.AuthenticationScheme, jwt =>
{
jwt.RequireHttpsMetadata = false;
jwt.IncludeErrorDetails = true;
jwt.MapInboundClaims = false;
jwt.SaveToken = false;
jwt.TokenValidationParameters = new TokenValidationParameters
{
ValidateIssuer = true,
ValidIssuer = issuer,
ValidateAudience = true,
ValidAudiences = new[] { audience },
ValidateIssuerSigningKey = true,
IssuerSigningKey = securityKey,
ValidateLifetime = true,
ClockSkew = TimeSpan.FromSeconds(30),
NameClaimType = System.Security.Claims.ClaimTypes.Name,
};
});
// Override authorization policies to use scope-based assertions that do NOT
// reference specific auth schemes (allowing any scheme to satisfy them).
services.RemoveAll<IConfigureOptions<AuthorizationOptions>>();
services.RemoveAll<IPostConfigureOptions<AuthorizationOptions>>();
services.AddAuthorization(auth =>
{
auth.AddPolicy("notify.viewer", policy =>
policy.RequireAuthenticatedUser()
.RequireAssertion(ctx => HasScope(ctx.User, "notify.viewer") ||
HasScope(ctx.User, "notify.operator") ||
HasScope(ctx.User, "notify.admin")));
auth.AddPolicy("notify.operator", policy =>
policy.RequireAuthenticatedUser()
.RequireAssertion(ctx => HasScope(ctx.User, "notify.operator") ||
HasScope(ctx.User, "notify.admin")));
auth.AddPolicy("notify.admin", policy =>
policy.RequireAuthenticatedUser()
.RequireAssertion(ctx => HasScope(ctx.User, "notify.admin")));
});
}
/// <summary>
/// Creates a JWT token using <see cref="JsonWebTokenHandler"/> which is compatible with
/// the default token handler in .NET 10's JwtBearer middleware. The legacy
/// <c>JwtSecurityTokenHandler</c> produces compact JWTs that the newer
/// <c>JsonWebTokenHandler</c> rejects with IDX14102.
/// </summary>
public static string CreateTestToken(
string signingKey,
string issuer,
string audience,
IEnumerable<Claim> claims,
DateTime? expires = null,
DateTime? notBefore = null)
{
var key = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(signingKey));
var handler = new JsonWebTokenHandler();
var descriptor = new SecurityTokenDescriptor
{
Issuer = issuer,
Audience = audience,
Expires = expires ?? DateTime.UtcNow.AddHours(1),
NotBefore = notBefore,
SigningCredentials = new SigningCredentials(key, SecurityAlgorithms.HmacSha256),
Subject = new ClaimsIdentity(claims)
};
return handler.CreateToken(descriptor);
}
/// <summary>
/// Creates a JWT token with the specified scopes.
/// </summary>
public static string CreateTestToken(
string signingKey,
string issuer,
string audience,
string[] scopes,
string? tenantId = null,
DateTime? expires = null,
DateTime? notBefore = null)
{
var claims = new List<Claim>
{
new(JwtRegisteredClaimNames.Sub, "test-user"),
new(JwtRegisteredClaimNames.Jti, Guid.NewGuid().ToString()),
};
if (tenantId is not null)
{
claims.Add(new Claim("tenant_id", tenantId));
}
foreach (var scope in scopes)
{
claims.Add(new Claim("scope", scope));
}
return CreateTestToken(signingKey, issuer, audience, claims, expires, notBefore);
}
private static bool HasScope(System.Security.Claims.ClaimsPrincipal user, string scope)
{
return user.Claims.Any(c =>
string.Equals(c.Type, "scope", StringComparison.OrdinalIgnoreCase) &&
c.Value.Split(' ').Contains(scope, StringComparer.Ordinal));
}
}

View File

@@ -6,18 +6,17 @@
using System;
using System.Collections.Generic;
using System.IdentityModel.Tokens.Jwt;
using System.Linq;
using System.Net;
using System.Net.Http;
using System.Net.Http.Headers;
using System.Security.Claims;
using System.Text;
using System.Text.Json.Nodes;
using System.Threading.Tasks;
using FluentAssertions;
using Microsoft.AspNetCore.Mvc.Testing;
using Microsoft.IdentityModel.Tokens;
using Microsoft.AspNetCore.TestHost;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Xunit;
using Xunit.v3;
@@ -42,16 +41,26 @@ public class NotifyWebServiceAuthTests : IClassFixture<WebApplicationFactory<Pro
{
_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:allowAnonymousFallback", "false"); // Deny by default
builder.UseSetting("notify:authority:adminScope", "notify.admin");
builder.UseSetting("notify:authority:operatorScope", "notify.operator");
builder.UseSetting("notify:authority:viewerScope", "notify.viewer");
builder.UseSetting("notify:telemetry:enableRequestLogging", "false");
builder.ConfigureAppConfiguration((_, config) =>
{
config.AddInMemoryCollection(new Dictionary<string, string?>
{
["notify:storage:driver"] = "memory",
["notify:authority:enabled"] = "false",
["notify:authority:developmentSigningKey"] = SigningKey,
["notify:authority:issuer"] = Issuer,
["notify:authority:audiences:0"] = Audience,
["notify:authority:allowAnonymousFallback"] = "false", // Deny by default
["notify:authority:adminScope"] = "notify.admin",
["notify:authority:operatorScope"] = "notify.operator",
["notify:authority:viewerScope"] = "notify.viewer",
["notify:telemetry:enableRequestLogging"] = "false",
});
});
builder.ConfigureTestServices(services =>
{
NotifyTestServiceOverrides.ReplaceWithInMemory(services, signingKey: SigningKey, issuer: Issuer, audience: Audience);
});
});
}
@@ -168,6 +177,7 @@ public class NotifyWebServiceAuthTests : IClassFixture<WebApplicationFactory<Pro
var client = _factory.CreateClient();
client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", validToken);
client.DefaultRequestHeaders.Add("X-StellaOps-Tenant", TestTenantId);
// Act
var response = await client.GetAsync("/api/v1/notify/rules", CancellationToken.None);
@@ -187,6 +197,7 @@ public class NotifyWebServiceAuthTests : IClassFixture<WebApplicationFactory<Pro
var viewerToken = CreateToken(TestTenantId, new[] { "notify.viewer" });
var client = _factory.CreateClient();
client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", viewerToken);
client.DefaultRequestHeaders.Add("X-StellaOps-Tenant", TestTenantId);
// Act
var response = await client.GetAsync("/api/v1/notify/rules", CancellationToken.None);
@@ -202,8 +213,9 @@ public class NotifyWebServiceAuthTests : IClassFixture<WebApplicationFactory<Pro
var viewerToken = CreateToken(TestTenantId, new[] { "notify.viewer" });
var client = _factory.CreateClient();
client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", viewerToken);
client.DefaultRequestHeaders.Add("X-StellaOps-Tenant", TestTenantId);
var payload = CreateRulePayload($"rule-viewer-{Guid.NewGuid():N}");
var payload = CreateRulePayload(Guid.NewGuid().ToString());
// Act
var response = await client.PostAsync(
@@ -222,8 +234,9 @@ public class NotifyWebServiceAuthTests : IClassFixture<WebApplicationFactory<Pro
var operatorToken = CreateToken(TestTenantId, new[] { "notify.viewer", "notify.operator" });
var client = _factory.CreateClient();
client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", operatorToken);
client.DefaultRequestHeaders.Add("X-StellaOps-Tenant", TestTenantId);
var payload = CreateRulePayload($"rule-operator-{Guid.NewGuid():N}");
var payload = CreateRulePayload(Guid.NewGuid().ToString());
// Act
var response = await client.PostAsync(
@@ -242,9 +255,10 @@ public class NotifyWebServiceAuthTests : IClassFixture<WebApplicationFactory<Pro
var operatorToken = CreateToken(TestTenantId, new[] { "notify.viewer", "notify.operator" });
var client = _factory.CreateClient();
client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", operatorToken);
client.DefaultRequestHeaders.Add("X-StellaOps-Tenant", TestTenantId);
// First create a rule
var ruleId = $"rule-delete-{Guid.NewGuid():N}";
var ruleId = Guid.NewGuid().ToString();
var payload = CreateRulePayload(ruleId);
await client.PostAsync(
"/api/v1/notify/rules",
@@ -265,6 +279,7 @@ public class NotifyWebServiceAuthTests : IClassFixture<WebApplicationFactory<Pro
var wrongScopeToken = CreateToken(TestTenantId, new[] { "some.other.scope" });
var client = _factory.CreateClient();
client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", wrongScopeToken);
client.DefaultRequestHeaders.Add("X-StellaOps-Tenant", TestTenantId);
// Act
var response = await client.GetAsync("/api/v1/notify/rules", CancellationToken.None);
@@ -280,8 +295,9 @@ public class NotifyWebServiceAuthTests : IClassFixture<WebApplicationFactory<Pro
var adminToken = CreateToken(TestTenantId, new[] { "notify.viewer", "notify.operator", "notify.admin" });
var client = _factory.CreateClient();
client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", adminToken);
client.DefaultRequestHeaders.Add("X-StellaOps-Tenant", TestTenantId);
var payload = CreateRulePayload("rule-internal-test");
var payload = CreateRulePayload(Guid.NewGuid().ToString());
// Act - internal normalize endpoint
var response = await client.PostAsync(
@@ -298,16 +314,16 @@ public class NotifyWebServiceAuthTests : IClassFixture<WebApplicationFactory<Pro
#region Tenant Isolation
[Fact]
public async Task CreateRule_UsesTokenTenantId()
public async Task CreateRule_UsesHeaderTenantId()
{
// Arrange
var operatorToken = CreateToken(TestTenantId, new[] { "notify.viewer", "notify.operator" });
var client = _factory.CreateClient();
client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", operatorToken);
client.DefaultRequestHeaders.Add("X-StellaOps-Tenant", TestTenantId);
var ruleId = $"rule-tenant-{Guid.NewGuid():N}";
var ruleId = Guid.NewGuid().ToString();
var payload = CreateRulePayload(ruleId);
// Payload has a tenantId, but should be overridden by token
// Act
await client.PostAsync(
@@ -320,7 +336,7 @@ public class NotifyWebServiceAuthTests : IClassFixture<WebApplicationFactory<Pro
var content = await response.Content.ReadAsStringAsync(CancellationToken.None);
var json = JsonNode.Parse(content);
// Assert - tenantId should match token, not payload
// Assert - tenantId should match header
json?["tenantId"]?.GetValue<string>().Should().Be(TestTenantId);
}
@@ -330,21 +346,23 @@ public class NotifyWebServiceAuthTests : IClassFixture<WebApplicationFactory<Pro
// Arrange - create rules with two different tenants
var tenant1Token = CreateToken(TestTenantId, new[] { "notify.viewer", "notify.operator" });
var tenant2Token = CreateToken(OtherTenantId, new[] { "notify.viewer", "notify.operator" });
var client1 = _factory.CreateClient();
client1.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", tenant1Token);
client1.DefaultRequestHeaders.Add("X-StellaOps-Tenant", TestTenantId);
var client2 = _factory.CreateClient();
client2.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", tenant2Token);
client2.DefaultRequestHeaders.Add("X-StellaOps-Tenant", OtherTenantId);
// Create rule for tenant 1
var rule1Id = $"rule-t1-{Guid.NewGuid():N}";
var rule1Id = Guid.NewGuid().ToString();
await client1.PostAsync(
"/api/v1/notify/rules",
new StringContent(CreateRulePayload(rule1Id).ToJsonString(), Encoding.UTF8, "application/json"));
// Create rule for tenant 2
var rule2Id = $"rule-t2-{Guid.NewGuid():N}";
var rule2Id = Guid.NewGuid().ToString();
await client2.PostAsync(
"/api/v1/notify/rules",
new StringContent(CreateRulePayload(rule2Id).ToJsonString(), Encoding.UTF8, "application/json"));
@@ -362,7 +380,7 @@ public class NotifyWebServiceAuthTests : IClassFixture<WebApplicationFactory<Pro
// Assert - each tenant only sees their own rules
rules1?.Any(r => r?["ruleId"]?.GetValue<string>() == rule1Id).Should().BeTrue();
rules1?.Any(r => r?["ruleId"]?.GetValue<string>() == rule2Id).Should().BeFalse();
rules2?.Any(r => r?["ruleId"]?.GetValue<string>() == rule2Id).Should().BeTrue();
rules2?.Any(r => r?["ruleId"]?.GetValue<string>() == rule1Id).Should().BeFalse();
}
@@ -373,11 +391,12 @@ public class NotifyWebServiceAuthTests : IClassFixture<WebApplicationFactory<Pro
// Arrange - create rule with tenant 1
var tenant1Token = CreateToken(TestTenantId, new[] { "notify.viewer", "notify.operator" });
var tenant2Token = CreateToken(OtherTenantId, new[] { "notify.viewer" });
var client1 = _factory.CreateClient();
client1.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", tenant1Token);
client1.DefaultRequestHeaders.Add("X-StellaOps-Tenant", TestTenantId);
var ruleId = $"rule-cross-tenant-{Guid.NewGuid():N}";
var ruleId = Guid.NewGuid().ToString();
await client1.PostAsync(
"/api/v1/notify/rules",
new StringContent(CreateRulePayload(ruleId).ToJsonString(), Encoding.UTF8, "application/json"));
@@ -385,6 +404,7 @@ public class NotifyWebServiceAuthTests : IClassFixture<WebApplicationFactory<Pro
// Act - tenant 2 tries to get tenant 1's rule
var client2 = _factory.CreateClient();
client2.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", tenant2Token);
client2.DefaultRequestHeaders.Add("X-StellaOps-Tenant", OtherTenantId);
var response = await client2.GetAsync($"/api/v1/notify/rules/{ruleId}");
// Assert - should be 404, not 403 (don't leak existence)
@@ -397,11 +417,12 @@ public class NotifyWebServiceAuthTests : IClassFixture<WebApplicationFactory<Pro
// Arrange - create rule with tenant 1
var tenant1Token = CreateToken(TestTenantId, new[] { "notify.viewer", "notify.operator" });
var tenant2Token = CreateToken(OtherTenantId, new[] { "notify.viewer", "notify.operator" });
var client1 = _factory.CreateClient();
client1.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", tenant1Token);
client1.DefaultRequestHeaders.Add("X-StellaOps-Tenant", TestTenantId);
var ruleId = $"rule-cross-delete-{Guid.NewGuid():N}";
var ruleId = Guid.NewGuid().ToString();
await client1.PostAsync(
"/api/v1/notify/rules",
new StringContent(CreateRulePayload(ruleId).ToJsonString(), Encoding.UTF8, "application/json"));
@@ -409,6 +430,7 @@ public class NotifyWebServiceAuthTests : IClassFixture<WebApplicationFactory<Pro
// Act - tenant 2 tries to delete tenant 1's rule
var client2 = _factory.CreateClient();
client2.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", tenant2Token);
client2.DefaultRequestHeaders.Add("X-StellaOps-Tenant", OtherTenantId);
var response = await client2.DeleteAsync($"/api/v1/notify/rules/{ruleId}");
// Assert - should be 404, not 204 (don't leak existence)
@@ -508,47 +530,33 @@ public class NotifyWebServiceAuthTests : IClassFixture<WebApplicationFactory<Pro
DateTime? expiresAt = null,
DateTime? notBefore = null)
{
var handler = new JwtSecurityTokenHandler();
var key = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(signingKey));
var credentials = new SigningCredentials(key, SecurityAlgorithms.HmacSha256);
var claims = new List<Claim>
{
new(JwtRegisteredClaimNames.Sub, "test-user"),
new(JwtRegisteredClaimNames.Jti, Guid.NewGuid().ToString()),
new("tenant_id", tenantId)
};
claims.AddRange(scopes.Select(s => new Claim("scope", s)));
var token = new JwtSecurityToken(
issuer: issuer,
audience: audience,
claims: claims,
notBefore: notBefore,
expires: expiresAt ?? DateTime.UtcNow.AddHours(1),
signingCredentials: credentials);
return handler.WriteToken(token);
return NotifyTestServiceOverrides.CreateTestToken(
signingKey, issuer, audience, scopes,
tenantId: tenantId, expires: expiresAt, notBefore: notBefore);
}
private static JsonObject CreateRulePayload(string ruleId)
{
return new JsonObject
{
["schemaVersion"] = "notify-rule@1",
["schemaVersion"] = "notify.rule@1",
["ruleId"] = ruleId,
["tenantId"] = TestTenantId,
["name"] = $"Test Rule {ruleId}",
["description"] = "Auth test rule",
["enabled"] = true,
["eventKinds"] = new JsonArray { "scan.completed" },
["match"] = new JsonObject
{
["eventKinds"] = new JsonArray { "scan.completed" }
},
["actions"] = new JsonArray
{
new JsonObject
{
["actionId"] = $"action-{Guid.NewGuid():N}",
["channel"] = "email:test",
["templateKey"] = "default"
["actionId"] = Guid.NewGuid().ToString(),
["channel"] = Guid.NewGuid().ToString(),
["template"] = "default",
["enabled"] = true
}
}
};

View File

@@ -6,19 +6,18 @@
using System;
using System.Collections.Generic;
using System.IdentityModel.Tokens.Jwt;
using System.Linq;
using System.Net;
using System.Net.Http;
using System.Net.Http.Headers;
using System.Security.Claims;
using System.Text;
using System.Text.Json;
using System.Text.Json.Nodes;
using System.Threading.Tasks;
using FluentAssertions;
using Microsoft.AspNetCore.Mvc.Testing;
using Microsoft.IdentityModel.Tokens;
using Microsoft.AspNetCore.TestHost;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Xunit;
using Xunit.v3;
@@ -45,16 +44,26 @@ public class NotifyWebServiceContractTests : IClassFixture<WebApplicationFactory
{
_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:allowAnonymousFallback", "false");
builder.UseSetting("notify:authority:adminScope", "notify.admin");
builder.UseSetting("notify:authority:operatorScope", "notify.operator");
builder.UseSetting("notify:authority:viewerScope", "notify.viewer");
builder.UseSetting("notify:telemetry:enableRequestLogging", "false");
builder.ConfigureAppConfiguration((_, config) =>
{
config.AddInMemoryCollection(new Dictionary<string, string?>
{
["notify:storage:driver"] = "memory",
["notify:authority:enabled"] = "false",
["notify:authority:developmentSigningKey"] = SigningKey,
["notify:authority:issuer"] = Issuer,
["notify:authority:audiences:0"] = Audience,
["notify:authority:allowAnonymousFallback"] = "false",
["notify:authority:adminScope"] = "notify.admin",
["notify:authority:operatorScope"] = "notify.operator",
["notify:authority:viewerScope"] = "notify.viewer",
["notify:telemetry:enableRequestLogging"] = "false",
});
});
builder.ConfigureTestServices(services =>
{
NotifyTestServiceOverrides.ReplaceWithInMemory(services, signingKey: SigningKey, issuer: Issuer, audience: Audience);
});
});
_viewerToken = CreateToken("notify.viewer");
@@ -104,8 +113,7 @@ public class NotifyWebServiceContractTests : IClassFixture<WebApplicationFactory
public async Task ListRules_ReturnsJsonArray()
{
// Arrange
var client = _factory.CreateClient();
client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", _viewerToken);
var client = CreateAuthenticatedClient(_viewerToken);
// Act
var response = await client.GetAsync("/api/v1/notify/rules", CancellationToken.None);
@@ -113,7 +121,7 @@ public class NotifyWebServiceContractTests : IClassFixture<WebApplicationFactory
// Assert
response.StatusCode.Should().Be(HttpStatusCode.OK);
response.Content.Headers.ContentType?.MediaType.Should().Be("application/json");
var content = await response.Content.ReadAsStringAsync(CancellationToken.None);
var json = JsonNode.Parse(content);
json.Should().NotBeNull();
@@ -124,10 +132,9 @@ public class NotifyWebServiceContractTests : IClassFixture<WebApplicationFactory
public async Task CreateRule_ValidPayload_Returns201WithLocation()
{
// Arrange
var client = _factory.CreateClient();
client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", _operatorToken);
var ruleId = $"rule-contract-{Guid.NewGuid():N}";
var client = CreateAuthenticatedClient(_operatorToken);
var ruleId = Guid.NewGuid().ToString();
var payload = CreateRulePayload(ruleId);
// Act
@@ -146,9 +153,8 @@ public class NotifyWebServiceContractTests : IClassFixture<WebApplicationFactory
public async Task CreateRule_InvalidPayload_Returns400()
{
// Arrange
var client = _factory.CreateClient();
client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", _operatorToken);
var client = CreateAuthenticatedClient(_operatorToken);
var invalidPayload = new JsonObject { ["invalid"] = "data" };
// Act
@@ -164,11 +170,10 @@ public class NotifyWebServiceContractTests : IClassFixture<WebApplicationFactory
public async Task GetRule_NotFound_Returns404()
{
// Arrange
var client = _factory.CreateClient();
client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", _viewerToken);
var client = CreateAuthenticatedClient(_viewerToken);
// Act
var response = await client.GetAsync("/api/v1/notify/rules/nonexistent-rule-id", CancellationToken.None);
var response = await client.GetAsync($"/api/v1/notify/rules/{Guid.NewGuid()}", CancellationToken.None);
// Assert
response.StatusCode.Should().Be(HttpStatusCode.NotFound);
@@ -178,11 +183,10 @@ public class NotifyWebServiceContractTests : IClassFixture<WebApplicationFactory
public async Task DeleteRule_Existing_Returns204()
{
// Arrange
var client = _factory.CreateClient();
client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", _operatorToken);
var client = CreateAuthenticatedClient(_operatorToken);
// First create a rule
var ruleId = $"rule-delete-{Guid.NewGuid():N}";
var ruleId = Guid.NewGuid().ToString();
var payload = CreateRulePayload(ruleId);
await client.PostAsync(
"/api/v1/notify/rules",
@@ -204,8 +208,7 @@ public class NotifyWebServiceContractTests : IClassFixture<WebApplicationFactory
public async Task ListChannels_ReturnsJsonArray()
{
// Arrange
var client = _factory.CreateClient();
client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", _viewerToken);
var client = CreateAuthenticatedClient(_viewerToken);
// Act
var response = await client.GetAsync("/api/v1/notify/channels", CancellationToken.None);
@@ -213,7 +216,7 @@ public class NotifyWebServiceContractTests : IClassFixture<WebApplicationFactory
// Assert
response.StatusCode.Should().Be(HttpStatusCode.OK);
response.Content.Headers.ContentType?.MediaType.Should().Be("application/json");
var content = await response.Content.ReadAsStringAsync(CancellationToken.None);
var json = JsonNode.Parse(content);
json.Should().NotBeNull();
@@ -224,10 +227,9 @@ public class NotifyWebServiceContractTests : IClassFixture<WebApplicationFactory
public async Task CreateChannel_ValidPayload_Returns201()
{
// Arrange
var client = _factory.CreateClient();
client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", _operatorToken);
var channelId = $"channel-contract-{Guid.NewGuid():N}";
var client = CreateAuthenticatedClient(_operatorToken);
var channelId = Guid.NewGuid().ToString();
var payload = CreateChannelPayload(channelId);
// Act
@@ -248,8 +250,7 @@ public class NotifyWebServiceContractTests : IClassFixture<WebApplicationFactory
public async Task ListTemplates_ReturnsJsonArray()
{
// Arrange
var client = _factory.CreateClient();
client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", _viewerToken);
var client = CreateAuthenticatedClient(_viewerToken);
// Act
var response = await client.GetAsync("/api/v1/notify/templates", CancellationToken.None);
@@ -266,9 +267,8 @@ public class NotifyWebServiceContractTests : IClassFixture<WebApplicationFactory
public async Task CreateTemplate_ValidPayload_Returns201()
{
// Arrange
var client = _factory.CreateClient();
client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", _operatorToken);
var client = CreateAuthenticatedClient(_operatorToken);
var templateId = Guid.NewGuid();
var payload = CreateTemplatePayload(templateId);
@@ -290,9 +290,8 @@ public class NotifyWebServiceContractTests : IClassFixture<WebApplicationFactory
public async Task CreateDelivery_ValidPayload_Returns201OrAccepted()
{
// Arrange
var client = _factory.CreateClient();
client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", _operatorToken);
var client = CreateAuthenticatedClient(_operatorToken);
var deliveryId = Guid.NewGuid();
var payload = CreateDeliveryPayload(deliveryId);
@@ -310,8 +309,7 @@ public class NotifyWebServiceContractTests : IClassFixture<WebApplicationFactory
public async Task ListDeliveries_ReturnsJsonArrayWithPagination()
{
// Arrange
var client = _factory.CreateClient();
client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", _viewerToken);
var client = CreateAuthenticatedClient(_viewerToken);
// Act
var response = await client.GetAsync("/api/v1/notify/deliveries?limit=10", CancellationToken.None);
@@ -321,15 +319,13 @@ public class NotifyWebServiceContractTests : IClassFixture<WebApplicationFactory
var content = await response.Content.ReadAsStringAsync(CancellationToken.None);
var json = JsonNode.Parse(content);
json.Should().NotBeNull();
json!.AsArray().Should().NotBeNull();
}
[Fact]
public async Task GetDelivery_NotFound_Returns404()
{
// Arrange
var client = _factory.CreateClient();
client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", _viewerToken);
var client = CreateAuthenticatedClient(_viewerToken);
// Act
var response = await client.GetAsync($"/api/v1/notify/deliveries/{Guid.NewGuid()}", CancellationToken.None);
@@ -346,10 +342,9 @@ public class NotifyWebServiceContractTests : IClassFixture<WebApplicationFactory
public async Task NormalizeRule_ValidPayload_ReturnsUpgradedSchema()
{
// Arrange
var client = _factory.CreateClient();
client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", _adminToken);
var payload = CreateRulePayload("rule-normalize-test");
var client = CreateAuthenticatedClient(_adminToken);
var payload = CreateRulePayload(Guid.NewGuid().ToString());
// Act
var response = await client.PostAsync(
@@ -372,10 +367,9 @@ public class NotifyWebServiceContractTests : IClassFixture<WebApplicationFactory
public async Task RuleResponse_ContainsRequiredFields()
{
// Arrange
var client = _factory.CreateClient();
client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", _operatorToken);
var ruleId = $"rule-shape-{Guid.NewGuid():N}";
var client = CreateAuthenticatedClient(_operatorToken);
var ruleId = Guid.NewGuid().ToString();
var payload = CreateRulePayload(ruleId);
await client.PostAsync(
"/api/v1/notify/rules",
@@ -390,7 +384,7 @@ public class NotifyWebServiceContractTests : IClassFixture<WebApplicationFactory
response.StatusCode.Should().Be(HttpStatusCode.OK);
var content = await response.Content.ReadAsStringAsync(CancellationToken.None);
var json = JsonNode.Parse(content);
// Verify required fields exist
json?["ruleId"].Should().NotBeNull();
json?["tenantId"].Should().NotBeNull();
@@ -404,10 +398,9 @@ public class NotifyWebServiceContractTests : IClassFixture<WebApplicationFactory
public async Task ChannelResponse_ContainsRequiredFields()
{
// Arrange
var client = _factory.CreateClient();
client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", _operatorToken);
var channelId = $"channel-shape-{Guid.NewGuid():N}";
var client = CreateAuthenticatedClient(_operatorToken);
var channelId = Guid.NewGuid().ToString();
var payload = CreateChannelPayload(channelId);
await client.PostAsync(
"/api/v1/notify/channels",
@@ -422,7 +415,7 @@ public class NotifyWebServiceContractTests : IClassFixture<WebApplicationFactory
response.StatusCode.Should().Be(HttpStatusCode.OK);
var content = await response.Content.ReadAsStringAsync(CancellationToken.None);
var json = JsonNode.Parse(content);
json?["channelId"].Should().NotBeNull();
json?["tenantId"].Should().NotBeNull();
json?["channelType"].Should().NotBeNull();
@@ -433,48 +426,42 @@ public class NotifyWebServiceContractTests : IClassFixture<WebApplicationFactory
#region Helper Methods
private HttpClient CreateAuthenticatedClient(string token)
{
var client = _factory.CreateClient();
client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", token);
client.DefaultRequestHeaders.Add("X-StellaOps-Tenant", TestTenantId);
return client;
}
private static string CreateToken(params string[] scopes)
{
var handler = new JwtSecurityTokenHandler();
var key = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(SigningKey));
var credentials = new SigningCredentials(key, SecurityAlgorithms.HmacSha256);
var claims = new List<Claim>
{
new(JwtRegisteredClaimNames.Sub, "test-user"),
new(JwtRegisteredClaimNames.Jti, Guid.NewGuid().ToString()),
new("tenant_id", TestTenantId)
};
claims.AddRange(scopes.Select(s => new Claim("scope", s)));
var token = new JwtSecurityToken(
issuer: Issuer,
audience: Audience,
claims: claims,
expires: DateTime.UtcNow.AddHours(1),
signingCredentials: credentials);
return handler.WriteToken(token);
return NotifyTestServiceOverrides.CreateTestToken(
SigningKey, Issuer, Audience, scopes, tenantId: TestTenantId);
}
private static JsonObject CreateRulePayload(string ruleId)
{
return new JsonObject
{
["schemaVersion"] = "notify-rule@1",
["schemaVersion"] = "notify.rule@1",
["ruleId"] = ruleId,
["tenantId"] = TestTenantId,
["name"] = $"Test Rule {ruleId}",
["description"] = "Contract test rule",
["enabled"] = true,
["eventKinds"] = new JsonArray { "scan.completed" },
["match"] = new JsonObject
{
["eventKinds"] = new JsonArray { "scan.completed" }
},
["actions"] = new JsonArray
{
new JsonObject
{
["actionId"] = $"action-{Guid.NewGuid():N}",
["channel"] = "email:test",
["templateKey"] = "default"
["actionId"] = Guid.NewGuid().ToString(),
["channel"] = Guid.NewGuid().ToString(),
["template"] = "default",
["enabled"] = true
}
}
};
@@ -484,17 +471,15 @@ public class NotifyWebServiceContractTests : IClassFixture<WebApplicationFactory
{
return new JsonObject
{
["schemaVersion"] = "notify-channel@1",
["schemaVersion"] = "notify.channel@1",
["channelId"] = channelId,
["tenantId"] = TestTenantId,
["channelType"] = "email",
["type"] = "email",
["name"] = $"Test Channel {channelId}",
["enabled"] = true,
["config"] = new JsonObject
{
["smtpHost"] = "localhost",
["smtpPort"] = 25,
["from"] = "test@example.com"
["target"] = "test@example.com"
}
};
}
@@ -503,7 +488,7 @@ public class NotifyWebServiceContractTests : IClassFixture<WebApplicationFactory
{
return new JsonObject
{
["schemaVersion"] = "notify-template@1",
["schemaVersion"] = "notify.template@1",
["templateId"] = templateId.ToString(),
["tenantId"] = TestTenantId,
["channelType"] = "email",
@@ -520,11 +505,11 @@ public class NotifyWebServiceContractTests : IClassFixture<WebApplicationFactory
{
["deliveryId"] = deliveryId.ToString(),
["tenantId"] = TestTenantId,
["channelId"] = "email:default",
["status"] = "pending",
["recipient"] = "test@example.com",
["subject"] = "Test Notification",
["body"] = "This is a test notification."
["ruleId"] = Guid.NewGuid().ToString(),
["actionId"] = Guid.NewGuid().ToString(),
["eventId"] = Guid.NewGuid().ToString(),
["kind"] = "scanner.report.ready",
["status"] = "pending"
};
}

View File

@@ -7,19 +7,18 @@
using System;
using System.Collections.Generic;
using System.Diagnostics;
using System.IdentityModel.Tokens.Jwt;
using System.Linq;
using System.Net;
using System.Net.Http;
using System.Net.Http.Headers;
using System.Security.Claims;
using System.Text;
using System.Text.Json.Nodes;
using System.Threading.Tasks;
using FluentAssertions;
using Microsoft.AspNetCore.Mvc.Testing;
using Microsoft.AspNetCore.TestHost;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.IdentityModel.Tokens;
using Xunit;
using Xunit.v3;
@@ -45,17 +44,27 @@ public class NotifyWebServiceOTelTests : IClassFixture<WebApplicationFactory<Pro
{
_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:allowAnonymousFallback", "false");
builder.UseSetting("notify:authority:adminScope", "notify.admin");
builder.UseSetting("notify:authority:operatorScope", "notify.operator");
builder.UseSetting("notify:authority:viewerScope", "notify.viewer");
builder.UseSetting("notify:telemetry:enableRequestLogging", "true");
builder.UseSetting("notify:telemetry:enableTracing", "true");
builder.ConfigureAppConfiguration((_, config) =>
{
config.AddInMemoryCollection(new Dictionary<string, string?>
{
["notify:storage:driver"] = "memory",
["notify:authority:enabled"] = "false",
["notify:authority:developmentSigningKey"] = SigningKey,
["notify:authority:issuer"] = Issuer,
["notify:authority:audiences:0"] = Audience,
["notify:authority:allowAnonymousFallback"] = "false",
["notify:authority:adminScope"] = "notify.admin",
["notify:authority:operatorScope"] = "notify.operator",
["notify:authority:viewerScope"] = "notify.viewer",
["notify:telemetry:enableRequestLogging"] = "true",
["notify:telemetry:enableTracing"] = "true",
});
});
builder.ConfigureTestServices(services =>
{
NotifyTestServiceOverrides.ReplaceWithInMemory(services, signingKey: SigningKey, issuer: Issuer, audience: Audience);
});
});
// Set up activity listener to capture spans
@@ -93,8 +102,9 @@ public class NotifyWebServiceOTelTests : IClassFixture<WebApplicationFactory<Pro
var operatorToken = CreateToken(TestTenantId, new[] { "notify.viewer", "notify.operator" });
var client = _factory.CreateClient();
client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", operatorToken);
client.DefaultRequestHeaders.Add("X-StellaOps-Tenant", TestTenantId);
var ruleId = $"rule-otel-{Guid.NewGuid():N}";
var ruleId = Guid.NewGuid().ToString();
var payload = CreateRulePayload(ruleId);
// Act
@@ -124,9 +134,10 @@ public class NotifyWebServiceOTelTests : IClassFixture<WebApplicationFactory<Pro
var operatorToken = CreateToken(TestTenantId, new[] { "notify.viewer", "notify.operator" });
var client = _factory.CreateClient();
client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", operatorToken);
client.DefaultRequestHeaders.Add("X-StellaOps-Tenant", TestTenantId);
// Create a rule first
var ruleId = $"rule-otel-get-{Guid.NewGuid():N}";
var ruleId = Guid.NewGuid().ToString();
var payload = CreateRulePayload(ruleId);
await client.PostAsync(
"/api/v1/notify/rules",
@@ -155,8 +166,9 @@ public class NotifyWebServiceOTelTests : IClassFixture<WebApplicationFactory<Pro
var operatorToken = CreateToken(TestTenantId, new[] { "notify.viewer", "notify.operator" });
var client = _factory.CreateClient();
client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", operatorToken);
client.DefaultRequestHeaders.Add("X-StellaOps-Tenant", TestTenantId);
var channelId = $"channel-otel-{Guid.NewGuid():N}";
var channelId = Guid.NewGuid().ToString();
var payload = CreateChannelPayload(channelId, "email");
// Act
@@ -178,6 +190,7 @@ public class NotifyWebServiceOTelTests : IClassFixture<WebApplicationFactory<Pro
var viewerToken = CreateToken(TestTenantId, new[] { "notify.viewer" });
var client = _factory.CreateClient();
client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", viewerToken);
client.DefaultRequestHeaders.Add("X-StellaOps-Tenant", TestTenantId);
// Act
var response = await client.GetAsync("/api/v1/notify/channels", CancellationToken.None);
@@ -199,6 +212,7 @@ public class NotifyWebServiceOTelTests : IClassFixture<WebApplicationFactory<Pro
var operatorToken = CreateToken(TestTenantId, new[] { "notify.viewer", "notify.operator" });
var client = _factory.CreateClient();
client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", operatorToken);
client.DefaultRequestHeaders.Add("X-StellaOps-Tenant", TestTenantId);
var deliveryId = Guid.NewGuid();
var payload = CreateDeliveryPayload(deliveryId, "test@example.com", "email:default");
@@ -222,6 +236,7 @@ public class NotifyWebServiceOTelTests : IClassFixture<WebApplicationFactory<Pro
var operatorToken = CreateToken(TestTenantId, new[] { "notify.viewer", "notify.operator" });
var client = _factory.CreateClient();
client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", operatorToken);
client.DefaultRequestHeaders.Add("X-StellaOps-Tenant", TestTenantId);
// Create a delivery first
var deliveryId = Guid.NewGuid();
@@ -248,6 +263,7 @@ public class NotifyWebServiceOTelTests : IClassFixture<WebApplicationFactory<Pro
var viewerToken = CreateToken(TestTenantId, new[] { "notify.viewer" });
var client = _factory.CreateClient();
client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", viewerToken);
client.DefaultRequestHeaders.Add("X-StellaOps-Tenant", TestTenantId);
// Act
var response = await client.GetAsync("/api/v1/notify/deliveries?limit=10&status=pending", CancellationToken.None);
@@ -269,6 +285,7 @@ public class NotifyWebServiceOTelTests : IClassFixture<WebApplicationFactory<Pro
var operatorToken = CreateToken(TestTenantId, new[] { "notify.viewer", "notify.operator" });
var client = _factory.CreateClient();
client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", operatorToken);
client.DefaultRequestHeaders.Add("X-StellaOps-Tenant", TestTenantId);
var templateId = Guid.NewGuid();
var payload = CreateTemplatePayload(templateId, "scan-report", "email");
@@ -296,6 +313,7 @@ public class NotifyWebServiceOTelTests : IClassFixture<WebApplicationFactory<Pro
var operatorToken = CreateToken(TestTenantId, new[] { "notify.viewer", "notify.operator" });
var client = _factory.CreateClient();
client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", operatorToken);
client.DefaultRequestHeaders.Add("X-StellaOps-Tenant", TestTenantId);
// Set up a parent trace context
var parentTraceId = ActivityTraceId.CreateRandom();
@@ -319,6 +337,7 @@ public class NotifyWebServiceOTelTests : IClassFixture<WebApplicationFactory<Pro
var viewerToken = CreateToken(TestTenantId, new[] { "notify.viewer" });
var client = _factory.CreateClient();
client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", viewerToken);
client.DefaultRequestHeaders.Add("X-StellaOps-Tenant", TestTenantId);
// Act
var response = await client.GetAsync("/api/v1/notify/rules", CancellationToken.None);
@@ -341,6 +360,7 @@ public class NotifyWebServiceOTelTests : IClassFixture<WebApplicationFactory<Pro
var operatorToken = CreateToken(TestTenantId, new[] { "notify.viewer", "notify.operator" });
var client = _factory.CreateClient();
client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", operatorToken);
client.DefaultRequestHeaders.Add("X-StellaOps-Tenant", TestTenantId);
var invalidPayload = new JsonObject { ["invalid"] = "payload" };
@@ -362,9 +382,10 @@ public class NotifyWebServiceOTelTests : IClassFixture<WebApplicationFactory<Pro
var viewerToken = CreateToken(TestTenantId, new[] { "notify.viewer" });
var client = _factory.CreateClient();
client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", viewerToken);
client.DefaultRequestHeaders.Add("X-StellaOps-Tenant", TestTenantId);
// Act
var response = await client.GetAsync($"/api/v1/notify/rules/nonexistent-{Guid.NewGuid():N}", CancellationToken.None);
var response = await client.GetAsync($"/api/v1/notify/rules/{Guid.NewGuid()}", CancellationToken.None);
// Assert
response.StatusCode.Should().Be(HttpStatusCode.NotFound);
@@ -399,6 +420,7 @@ public class NotifyWebServiceOTelTests : IClassFixture<WebApplicationFactory<Pro
var viewerToken = CreateToken(TestTenantId, new[] { "notify.viewer" });
var client = _factory.CreateClient();
client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", viewerToken);
client.DefaultRequestHeaders.Add("X-StellaOps-Tenant", TestTenantId);
// Act
var response = await client.GetAsync("/api/v1/notify/rules", CancellationToken.None);
@@ -422,45 +444,31 @@ public class NotifyWebServiceOTelTests : IClassFixture<WebApplicationFactory<Pro
private static string CreateToken(string tenantId, string[] scopes)
{
var handler = new JwtSecurityTokenHandler();
var key = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(SigningKey));
var credentials = new SigningCredentials(key, SecurityAlgorithms.HmacSha256);
var claims = new List<Claim>
{
new(JwtRegisteredClaimNames.Sub, "test-user"),
new(JwtRegisteredClaimNames.Jti, Guid.NewGuid().ToString()),
new("tenant_id", tenantId)
};
claims.AddRange(scopes.Select(s => new Claim("scope", s)));
var token = new JwtSecurityToken(
issuer: Issuer,
audience: Audience,
claims: claims,
expires: DateTime.UtcNow.AddHours(1),
signingCredentials: credentials);
return handler.WriteToken(token);
return NotifyTestServiceOverrides.CreateTestToken(
SigningKey, Issuer, Audience, scopes, tenantId: tenantId);
}
private static JsonObject CreateRulePayload(string ruleId)
{
return new JsonObject
{
["schemaVersion"] = "notify-rule@1",
["schemaVersion"] = "notify.rule@1",
["ruleId"] = ruleId,
["tenantId"] = TestTenantId,
["name"] = $"Test Rule {ruleId}",
["enabled"] = true,
["eventKinds"] = new JsonArray { "scan.completed" },
["match"] = new JsonObject
{
["eventKinds"] = new JsonArray { "scan.completed" }
},
["actions"] = new JsonArray
{
new JsonObject
{
["actionId"] = $"action-{Guid.NewGuid():N}",
["channel"] = "email:test",
["templateKey"] = "default"
["actionId"] = Guid.NewGuid().ToString(),
["channel"] = Guid.NewGuid().ToString(),
["template"] = "default",
["enabled"] = true
}
}
};
@@ -470,17 +478,15 @@ public class NotifyWebServiceOTelTests : IClassFixture<WebApplicationFactory<Pro
{
return new JsonObject
{
["schemaVersion"] = "notify-channel@1",
["schemaVersion"] = "notify.channel@1",
["channelId"] = channelId,
["tenantId"] = TestTenantId,
["channelType"] = channelType,
["type"] = channelType,
["name"] = $"Test Channel {channelId}",
["enabled"] = true,
["config"] = new JsonObject
{
["smtpHost"] = "localhost",
["smtpPort"] = 25,
["from"] = "test@example.com"
["target"] = "test@example.com"
}
};
}
@@ -491,11 +497,11 @@ public class NotifyWebServiceOTelTests : IClassFixture<WebApplicationFactory<Pro
{
["deliveryId"] = deliveryId.ToString(),
["tenantId"] = TestTenantId,
["channelId"] = channelId,
["status"] = "pending",
["recipient"] = recipient,
["subject"] = "Test Notification",
["body"] = "This is a test notification."
["ruleId"] = Guid.NewGuid().ToString(),
["actionId"] = Guid.NewGuid().ToString(),
["eventId"] = Guid.NewGuid().ToString(),
["kind"] = "scanner.report.ready",
["status"] = "pending"
};
}
@@ -503,7 +509,7 @@ public class NotifyWebServiceOTelTests : IClassFixture<WebApplicationFactory<Pro
{
return new JsonObject
{
["schemaVersion"] = "notify-template@1",
["schemaVersion"] = "notify.template@1",
["templateId"] = templateId.ToString(),
["tenantId"] = TestTenantId,
["channelType"] = channelType,