Refactor code structure for improved readability and maintainability; removed redundant code blocks and optimized function calls.
This commit is contained in:
@@ -0,0 +1,89 @@
|
||||
using System.Net;
|
||||
using System.Net.Http;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Microsoft.Extensions.Options;
|
||||
using StellaOps.Notifier.Worker.Options;
|
||||
using StellaOps.Notifier.Worker.Processing;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Notifier.Tests;
|
||||
|
||||
public class HttpEgressSloSinkTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task PublishAsync_NoWebhook_DoesNothing()
|
||||
{
|
||||
var handler = new StubHandler();
|
||||
var sink = CreateSink(handler, new EgressSloOptions { Webhook = null });
|
||||
|
||||
await sink.PublishAsync(BuildContext(), CancellationToken.None);
|
||||
|
||||
Assert.Equal(0, handler.SendCount);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task PublishAsync_SendsWebhookWithPayload()
|
||||
{
|
||||
var handler = new StubHandler();
|
||||
var sink = CreateSink(handler, new EgressSloOptions { Webhook = "https://example.test/slo", TimeoutSeconds = 5 });
|
||||
|
||||
await sink.PublishAsync(BuildContext(), CancellationToken.None);
|
||||
|
||||
Assert.Equal(1, handler.SendCount);
|
||||
var request = handler.LastRequest!;
|
||||
Assert.Equal(HttpMethod.Post, request.Method);
|
||||
Assert.Equal("https://example.test/slo", request.RequestUri!.ToString());
|
||||
}
|
||||
|
||||
private static HttpEgressSloSink CreateSink(HttpMessageHandler handler, EgressSloOptions options)
|
||||
{
|
||||
var factory = new StubHttpClientFactory(handler);
|
||||
return new HttpEgressSloSink(factory, Options.Create(options), NullLogger<HttpEgressSloSink>.Instance);
|
||||
}
|
||||
|
||||
private static EgressSloContext BuildContext()
|
||||
{
|
||||
var evt = Notify.Models.NotifyEvent.Create(
|
||||
Guid.NewGuid(),
|
||||
kind: "policy.violation",
|
||||
tenant: "tenant-a",
|
||||
ts: DateTimeOffset.UtcNow,
|
||||
payload: new System.Text.Json.Nodes.JsonObject(),
|
||||
actor: "tester",
|
||||
version: "1");
|
||||
|
||||
var ctx = EgressSloContext.FromNotifyEvent(evt);
|
||||
ctx.AddDelivery("Slack", "tmpl", evt.Kind);
|
||||
return ctx;
|
||||
}
|
||||
|
||||
private sealed class StubHandler : HttpMessageHandler
|
||||
{
|
||||
public int SendCount { get; private set; }
|
||||
public HttpRequestMessage? LastRequest { get; private set; }
|
||||
|
||||
protected override Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
|
||||
{
|
||||
SendCount++;
|
||||
LastRequest = request;
|
||||
return Task.FromResult(new HttpResponseMessage(HttpStatusCode.OK));
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class StubHttpClientFactory : IHttpClientFactory
|
||||
{
|
||||
private readonly HttpMessageHandler _handler;
|
||||
|
||||
public StubHttpClientFactory(HttpMessageHandler handler)
|
||||
{
|
||||
_handler = handler;
|
||||
}
|
||||
|
||||
public HttpClient CreateClient(string name)
|
||||
{
|
||||
return new HttpClient(_handler, disposeHandler: false);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
using System.Collections.Concurrent;
|
||||
using System.Collections.Generic;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using StellaOps.Notifier.Worker.Processing;
|
||||
|
||||
namespace StellaOps.Notifier.Tests.Support;
|
||||
|
||||
public sealed class TestEgressSloSink : IEgressSloSink
|
||||
{
|
||||
private readonly ConcurrentBag<EgressSloContext> _contexts = new();
|
||||
|
||||
public IReadOnlyCollection<EgressSloContext> Contexts => _contexts;
|
||||
|
||||
public Task PublishAsync(EgressSloContext context, CancellationToken cancellationToken)
|
||||
{
|
||||
_contexts.Add(context);
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
namespace StellaOps.Notifier.Worker.Options;
|
||||
|
||||
public sealed class EgressSloOptions
|
||||
{
|
||||
/// <summary>
|
||||
/// Webhook endpoint to receive SLO delivery signals. When null/empty, publishing is disabled.
|
||||
/// </summary>
|
||||
public string? Webhook { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Request timeout in seconds for the webhook call.
|
||||
/// </summary>
|
||||
public int TimeoutSeconds { get; set; } = 5;
|
||||
|
||||
public bool Enabled => !string.IsNullOrWhiteSpace(Webhook);
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
using System.Collections.Generic;
|
||||
using StellaOps.Notify.Models;
|
||||
|
||||
namespace StellaOps.Notifier.Worker.Processing;
|
||||
|
||||
/// <summary>
|
||||
/// Tracks per-event delivery intents for SLO evaluation and webhook emission.
|
||||
/// </summary>
|
||||
internal sealed class EgressSloContext
|
||||
{
|
||||
private readonly List<EgressSloSignal> _signals = new();
|
||||
|
||||
public IReadOnlyList<EgressSloSignal> Signals => _signals;
|
||||
|
||||
public static EgressSloContext FromNotifyEvent(NotifyEvent notifyEvent)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(notifyEvent);
|
||||
return new EgressSloContext
|
||||
{
|
||||
EventId = notifyEvent.EventId,
|
||||
TenantId = notifyEvent.Tenant,
|
||||
EventKind = notifyEvent.Kind,
|
||||
OccurredAt = notifyEvent.Ts
|
||||
};
|
||||
}
|
||||
|
||||
public Guid EventId { get; private set; }
|
||||
|
||||
public string TenantId { get; private set; } = string.Empty;
|
||||
|
||||
public string EventKind { get; private set; } = string.Empty;
|
||||
|
||||
public DateTimeOffset OccurredAt { get; private set; }
|
||||
|
||||
public void AddDelivery(string channelType, string template, string kind)
|
||||
{
|
||||
_signals.Add(new EgressSloSignal(channelType, template, kind, OccurredAt));
|
||||
}
|
||||
}
|
||||
|
||||
internal sealed record EgressSloSignal(
|
||||
string Channel,
|
||||
string Template,
|
||||
string Kind,
|
||||
DateTimeOffset OccurredAt);
|
||||
@@ -0,0 +1,93 @@
|
||||
using System.Net.Http;
|
||||
using System.Net.Http.Json;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Options;
|
||||
using StellaOps.Notifier.Worker.Options;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace StellaOps.Notifier.Worker.Processing;
|
||||
|
||||
internal sealed class HttpEgressSloSink : IEgressSloSink
|
||||
{
|
||||
private readonly IHttpClientFactory _clientFactory;
|
||||
private readonly EgressSloOptions _options;
|
||||
private readonly ILogger<HttpEgressSloSink> _logger;
|
||||
|
||||
public HttpEgressSloSink(
|
||||
IHttpClientFactory clientFactory,
|
||||
IOptions<EgressSloOptions> options,
|
||||
ILogger<HttpEgressSloSink> logger)
|
||||
{
|
||||
_clientFactory = clientFactory ?? throw new ArgumentNullException(nameof(clientFactory));
|
||||
_options = options?.Value ?? throw new ArgumentNullException(nameof(options));
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
}
|
||||
|
||||
public async Task PublishAsync(EgressSloContext context, CancellationToken cancellationToken)
|
||||
{
|
||||
if (context is null)
|
||||
{
|
||||
throw new ArgumentNullException(nameof(context));
|
||||
}
|
||||
|
||||
if (!_options.Enabled)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
using var linkedCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
|
||||
if (_options.TimeoutSeconds > 0)
|
||||
{
|
||||
linkedCts.CancelAfter(TimeSpan.FromSeconds(_options.TimeoutSeconds));
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var client = _clientFactory.CreateClient("notifier-slo-webhook");
|
||||
var payload = Map(context);
|
||||
|
||||
var response = await client.PostAsJsonAsync(_options.Webhook, payload, linkedCts.Token).ConfigureAwait(false);
|
||||
if (!response.IsSuccessStatusCode)
|
||||
{
|
||||
_logger.LogWarning(
|
||||
"SLO webhook returned non-success status {StatusCode} for event {EventId} (tenant {TenantId}).",
|
||||
(int)response.StatusCode,
|
||||
context.EventId,
|
||||
context.TenantId);
|
||||
}
|
||||
}
|
||||
catch (OperationCanceledException) when (linkedCts.IsCancellationRequested)
|
||||
{
|
||||
_logger.LogWarning(
|
||||
"SLO webhook timed out after {TimeoutSeconds}s for event {EventId} (tenant {TenantId}).",
|
||||
_options.TimeoutSeconds,
|
||||
context.EventId,
|
||||
context.TenantId);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(
|
||||
ex,
|
||||
"Failed to publish SLO webhook for event {EventId} (tenant {TenantId}).",
|
||||
context.EventId,
|
||||
context.TenantId);
|
||||
}
|
||||
}
|
||||
|
||||
private static object Map(EgressSloContext context)
|
||||
=> new
|
||||
{
|
||||
context.EventId,
|
||||
context.TenantId,
|
||||
context.EventKind,
|
||||
context.OccurredAt,
|
||||
deliveries = context.Signals.Select(signal => new
|
||||
{
|
||||
signal.Channel,
|
||||
signal.Template,
|
||||
signal.Kind,
|
||||
signal.OccurredAt
|
||||
})
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
namespace StellaOps.Notifier.Worker.Processing;
|
||||
|
||||
internal interface IEgressSloSink
|
||||
{
|
||||
Task PublishAsync(EgressSloContext context, CancellationToken cancellationToken);
|
||||
}
|
||||
|
||||
internal sealed class NullEgressSloSink : IEgressSloSink
|
||||
{
|
||||
public Task PublishAsync(EgressSloContext context, CancellationToken cancellationToken)
|
||||
{
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
# QA Playbook — Attestation Routing (NOTIFY-ATTEST-74-002)
|
||||
|
||||
## Goal
|
||||
Verify attestation-related notification flows using the sample rules shipped in `docs/attestation-rules.sample.json`.
|
||||
|
||||
## Prereqs
|
||||
- Notifier WebService + Worker running against a QA tenant.
|
||||
- Channels configured for:
|
||||
- `email-kms` (SMTP bridge)
|
||||
- `webhook-kms` (internal hook)
|
||||
- `slack-soc` (Slack webhook)
|
||||
- `webhook-siem`
|
||||
- Templates pre-seeded from `offline/notifier/templates/attestation/*.json`.
|
||||
|
||||
## Steps
|
||||
1. Import rules and channels
|
||||
- `POST /api/v1/notify/rules:batch` with `docs/attestation-rules.sample.json` (replace `<tenant-id>`).
|
||||
- Verify rules are enabled.
|
||||
2. Emit events
|
||||
- Rotation: emit `authority.keys.rotated` with signer metadata and impacted tenants.
|
||||
- Revocation: `authority.keys.revoked`.
|
||||
- Transparency anomaly: `attestor.transparency.anomaly` and `attestor.transparency.witness.failed`.
|
||||
3. Validate deliveries
|
||||
- Confirm email + webhook for rotation/revocation (template `tmpl-attest-key-rotation`).
|
||||
- Confirm slack + webhook for transparency anomaly (template `tmpl-attest-transparency-anomaly`).
|
||||
- Check ledger/DB for rendered payloads with template keys and tenant id.
|
||||
4. Negative checks
|
||||
- Disabled channel should suppress delivery.
|
||||
- Missing template should surface as rule error.
|
||||
|
||||
## Evidence to capture
|
||||
- API responses for rule import and event POST.
|
||||
- Delivery records (IDs, channel, template key) per event.
|
||||
- Slack/email/webhook payload excerpts (hash or screenshot acceptable).
|
||||
|
||||
## Completion criteria
|
||||
- All four event kinds produce expected channels per sample rules without errors.
|
||||
- Ledger shows template IDs `tmpl-attest-key-rotation` and `tmpl-attest-transparency-anomaly`.
|
||||
- Failures (if any) documented with event payload and channel.
|
||||
@@ -0,0 +1,86 @@
|
||||
{
|
||||
"rules": [
|
||||
{
|
||||
"ruleId": "attest-key-rotation",
|
||||
"name": "Attestation key rotation/revocation",
|
||||
"enabled": true,
|
||||
"tenantId": "<tenant-id>",
|
||||
"match": {
|
||||
"eventKinds": [
|
||||
"authority.keys.rotated",
|
||||
"authority.keys.revoked"
|
||||
]
|
||||
},
|
||||
"actions": [
|
||||
{
|
||||
"actionId": "email-kms",
|
||||
"enabled": true,
|
||||
"channel": "email-kms",
|
||||
"template": "tmpl-attest-key-rotation"
|
||||
},
|
||||
{
|
||||
"actionId": "webhook-kms",
|
||||
"enabled": true,
|
||||
"channel": "webhook-kms",
|
||||
"template": "tmpl-attest-key-rotation"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"ruleId": "attest-transparency-anomaly",
|
||||
"name": "Transparency witness anomaly",
|
||||
"enabled": true,
|
||||
"tenantId": "<tenant-id>",
|
||||
"match": {
|
||||
"eventKinds": [
|
||||
"attestor.transparency.anomaly",
|
||||
"attestor.transparency.witness.failed"
|
||||
]
|
||||
},
|
||||
"actions": [
|
||||
{
|
||||
"actionId": "slack-soc",
|
||||
"enabled": true,
|
||||
"channel": "slack-soc",
|
||||
"template": "tmpl-attest-transparency-anomaly"
|
||||
},
|
||||
{
|
||||
"actionId": "webhook-siem",
|
||||
"enabled": true,
|
||||
"channel": "webhook-siem",
|
||||
"template": "tmpl-attest-transparency-anomaly"
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"channels": [
|
||||
{
|
||||
"channelId": "email-kms",
|
||||
"type": "email",
|
||||
"name": "KMS security",
|
||||
"target": "kms-security@example.com",
|
||||
"secretRef": "ref://notify/channels/email/kms-security"
|
||||
},
|
||||
{
|
||||
"channelId": "webhook-kms",
|
||||
"type": "webhook",
|
||||
"name": "KMS webhook",
|
||||
"endpoint": "https://hooks.internal/kms",
|
||||
"secretRef": "ref://notify/channels/webhook/kms"
|
||||
},
|
||||
{
|
||||
"channelId": "slack-soc",
|
||||
"type": "slack",
|
||||
"name": "SOC high-priority",
|
||||
"endpoint": "https://hooks.slack.com/services/T000/B000/XYZ",
|
||||
"secretRef": "ref://notify/channels/slack/soc"
|
||||
},
|
||||
{
|
||||
"channelId": "webhook-siem",
|
||||
"type": "webhook",
|
||||
"name": "SIEM ingest",
|
||||
"endpoint": "https://siem.example.internal/hooks/notifier",
|
||||
"secretRef": "ref://notify/channels/webhook/siem"
|
||||
}
|
||||
]
|
||||
}
|
||||
Reference in New Issue
Block a user