Some checks failed
AOC Guard CI / aoc-guard (push) Has been cancelled
AOC Guard CI / aoc-verify (push) Has been cancelled
Docs CI / lint-and-preview (push) Has been cancelled
Policy Lint & Smoke / policy-lint (push) Has been cancelled
api-governance / spectral-lint (push) Has been cancelled
oas-ci / oas-validate (push) Has been cancelled
Policy Simulation / policy-simulate (push) Has been cancelled
sdk-generator-smoke / sdk-smoke (push) Has been cancelled
SDK Publish & Sign / sdk-publish (push) Has been cancelled
341 lines
11 KiB
C#
341 lines
11 KiB
C#
using Microsoft.Extensions.Logging.Abstractions;
|
|
using StellaOps.Notify.Models;
|
|
using StellaOps.Notifier.Worker.Templates;
|
|
using Xunit;
|
|
|
|
namespace StellaOps.Notifier.Tests.Templates;
|
|
|
|
public sealed class NotifyTemplateServiceTests
|
|
{
|
|
private readonly InMemoryTemplateRepository _templateRepository;
|
|
private readonly InMemoryAuditRepository _auditRepository;
|
|
private readonly NotifyTemplateService _service;
|
|
|
|
public NotifyTemplateServiceTests()
|
|
{
|
|
_templateRepository = new InMemoryTemplateRepository();
|
|
_auditRepository = new InMemoryAuditRepository();
|
|
_service = new NotifyTemplateService(
|
|
_templateRepository,
|
|
_auditRepository,
|
|
NullLogger<NotifyTemplateService>.Instance);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task ResolveAsync_ExactLocaleMatch_ReturnsTemplate()
|
|
{
|
|
// Arrange
|
|
var template = CreateTemplate("tmpl-001", "pack.approval", "en-us");
|
|
await _templateRepository.UpsertAsync(template);
|
|
|
|
// Act
|
|
var result = await _service.ResolveAsync(
|
|
"test-tenant", "pack.approval", NotifyChannelType.Webhook, "en-US");
|
|
|
|
// Assert
|
|
Assert.NotNull(result);
|
|
Assert.Equal("tmpl-001", result.TemplateId);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task ResolveAsync_FallbackToLanguageOnly_ReturnsTemplate()
|
|
{
|
|
// Arrange
|
|
var template = CreateTemplate("tmpl-en", "pack.approval", "en");
|
|
await _templateRepository.UpsertAsync(template);
|
|
|
|
// Act - request en-GB but only en exists
|
|
var result = await _service.ResolveAsync(
|
|
"test-tenant", "pack.approval", NotifyChannelType.Webhook, "en-GB");
|
|
|
|
// Assert
|
|
Assert.NotNull(result);
|
|
Assert.Equal("tmpl-en", result.TemplateId);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task ResolveAsync_FallbackToDefault_ReturnsTemplate()
|
|
{
|
|
// Arrange
|
|
var template = CreateTemplate("tmpl-default", "pack.approval", "en-us");
|
|
await _templateRepository.UpsertAsync(template);
|
|
|
|
// Act - request de-DE but only en-us exists (default)
|
|
var result = await _service.ResolveAsync(
|
|
"test-tenant", "pack.approval", NotifyChannelType.Webhook, "de-DE");
|
|
|
|
// Assert
|
|
Assert.NotNull(result);
|
|
Assert.Equal("tmpl-default", result.TemplateId);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task ResolveAsync_NoMatch_ReturnsNull()
|
|
{
|
|
// Act
|
|
var result = await _service.ResolveAsync(
|
|
"test-tenant", "nonexistent.key", NotifyChannelType.Webhook, "en-US");
|
|
|
|
// Assert
|
|
Assert.Null(result);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task UpsertAsync_NewTemplate_CreatesAndAudits()
|
|
{
|
|
// Arrange
|
|
var template = CreateTemplate("tmpl-new", "pack.approval", "en-us");
|
|
|
|
// Act
|
|
var result = await _service.UpsertAsync(template, "test-actor");
|
|
|
|
// Assert
|
|
Assert.True(result.Success);
|
|
Assert.True(result.IsNew);
|
|
Assert.Equal("tmpl-new", result.TemplateId);
|
|
|
|
var audit = _auditRepository.Entries.Single();
|
|
Assert.Equal("template.created", audit.EventType);
|
|
Assert.Equal("test-actor", audit.Actor);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task UpsertAsync_ExistingTemplate_UpdatesAndAudits()
|
|
{
|
|
// Arrange
|
|
var original = CreateTemplate("tmpl-existing", "pack.approval", "en-us", "Original body");
|
|
await _templateRepository.UpsertAsync(original);
|
|
_auditRepository.Entries.Clear();
|
|
|
|
var updated = CreateTemplate("tmpl-existing", "pack.approval", "en-us", "Updated body");
|
|
|
|
// Act
|
|
var result = await _service.UpsertAsync(updated, "another-actor");
|
|
|
|
// Assert
|
|
Assert.True(result.Success);
|
|
Assert.False(result.IsNew);
|
|
|
|
var audit = _auditRepository.Entries.Single();
|
|
Assert.Equal("template.updated", audit.EventType);
|
|
Assert.Equal("another-actor", audit.Actor);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task UpsertAsync_InvalidTemplate_ReturnsError()
|
|
{
|
|
// Arrange - template with mismatched braces
|
|
var template = NotifyTemplate.Create(
|
|
templateId: "tmpl-invalid",
|
|
tenantId: "test-tenant",
|
|
channelType: NotifyChannelType.Webhook,
|
|
key: "test.key",
|
|
locale: "en-us",
|
|
body: "Hello {{name} - missing closing brace");
|
|
|
|
// Act
|
|
var result = await _service.UpsertAsync(template, "test-actor");
|
|
|
|
// Assert
|
|
Assert.False(result.Success);
|
|
Assert.Contains("braces", result.Error!, StringComparison.OrdinalIgnoreCase);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task DeleteAsync_ExistingTemplate_DeletesAndAudits()
|
|
{
|
|
// Arrange
|
|
var template = CreateTemplate("tmpl-delete", "pack.approval", "en-us");
|
|
await _templateRepository.UpsertAsync(template);
|
|
|
|
// Act
|
|
var deleted = await _service.DeleteAsync("test-tenant", "tmpl-delete", "delete-actor");
|
|
|
|
// Assert
|
|
Assert.True(deleted);
|
|
Assert.Null(await _templateRepository.GetAsync("test-tenant", "tmpl-delete"));
|
|
|
|
var audit = _auditRepository.Entries.Last();
|
|
Assert.Equal("template.deleted", audit.EventType);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task DeleteAsync_NonexistentTemplate_ReturnsFalse()
|
|
{
|
|
// Act
|
|
var deleted = await _service.DeleteAsync("test-tenant", "nonexistent", "actor");
|
|
|
|
// Assert
|
|
Assert.False(deleted);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task ListAsync_WithFilters_ReturnsFilteredResults()
|
|
{
|
|
// Arrange
|
|
await _templateRepository.UpsertAsync(CreateTemplate("tmpl-1", "pack.approval", "en-us"));
|
|
await _templateRepository.UpsertAsync(CreateTemplate("tmpl-2", "pack.approval", "de-de"));
|
|
await _templateRepository.UpsertAsync(CreateTemplate("tmpl-3", "risk.alert", "en-us"));
|
|
|
|
// Act
|
|
var results = await _service.ListAsync("test-tenant", new TemplateListOptions
|
|
{
|
|
KeyPrefix = "pack.",
|
|
Locale = "en-us"
|
|
});
|
|
|
|
// Assert
|
|
Assert.Single(results);
|
|
Assert.Equal("tmpl-1", results[0].TemplateId);
|
|
}
|
|
|
|
[Fact]
|
|
public void Validate_ValidTemplate_ReturnsValid()
|
|
{
|
|
// Act
|
|
var result = _service.Validate("Hello {{name}}, your order {{orderId}} is ready.");
|
|
|
|
// Assert
|
|
Assert.True(result.IsValid);
|
|
Assert.Empty(result.Errors);
|
|
}
|
|
|
|
[Fact]
|
|
public void Validate_MismatchedBraces_ReturnsInvalid()
|
|
{
|
|
// Act
|
|
var result = _service.Validate("Hello {{name}, missing close");
|
|
|
|
// Assert
|
|
Assert.False(result.IsValid);
|
|
Assert.Contains(result.Errors, e => e.Contains("braces"));
|
|
}
|
|
|
|
[Fact]
|
|
public void Validate_UnclosedEachBlock_ReturnsInvalid()
|
|
{
|
|
// Act
|
|
var result = _service.Validate("{{#each items}}{{this}}");
|
|
|
|
// Assert
|
|
Assert.False(result.IsValid);
|
|
Assert.Contains(result.Errors, e => e.Contains("#each"));
|
|
}
|
|
|
|
[Fact]
|
|
public void Validate_SensitiveVariable_ReturnsWarning()
|
|
{
|
|
// Act
|
|
var result = _service.Validate("Your API key is: {{apiKey}}");
|
|
|
|
// Assert
|
|
Assert.True(result.IsValid);
|
|
Assert.Contains(result.Warnings, w => w.Contains("sensitive"));
|
|
}
|
|
|
|
[Fact]
|
|
public void GetRedactionConfig_DefaultMode_ReturnsSafeDefaults()
|
|
{
|
|
// Arrange
|
|
var template = CreateTemplate("tmpl-001", "test.key", "en-us");
|
|
|
|
// Act
|
|
var config = _service.GetRedactionConfig(template);
|
|
|
|
// Assert
|
|
Assert.Equal("safe", config.Mode);
|
|
Assert.Contains("secret", config.DeniedFields);
|
|
Assert.Contains("password", config.DeniedFields);
|
|
}
|
|
|
|
[Fact]
|
|
public void GetRedactionConfig_ParanoidMode_RequiresAllowlist()
|
|
{
|
|
// Arrange
|
|
var template = NotifyTemplate.Create(
|
|
templateId: "tmpl-paranoid",
|
|
tenantId: "test-tenant",
|
|
channelType: NotifyChannelType.Webhook,
|
|
key: "test.key",
|
|
locale: "en-us",
|
|
body: "Test body",
|
|
metadata: new Dictionary<string, string>
|
|
{
|
|
["redaction"] = "paranoid",
|
|
["redaction.allow"] = "name,email"
|
|
});
|
|
|
|
// Act
|
|
var config = _service.GetRedactionConfig(template);
|
|
|
|
// Assert
|
|
Assert.Equal("paranoid", config.Mode);
|
|
Assert.Contains("name", config.AllowedFields);
|
|
Assert.Contains("email", config.AllowedFields);
|
|
}
|
|
|
|
private static NotifyTemplate CreateTemplate(
|
|
string templateId,
|
|
string key,
|
|
string locale,
|
|
string body = "Test body {{variable}}")
|
|
{
|
|
return NotifyTemplate.Create(
|
|
templateId: templateId,
|
|
tenantId: "test-tenant",
|
|
channelType: NotifyChannelType.Webhook,
|
|
key: key,
|
|
locale: locale,
|
|
body: body);
|
|
}
|
|
|
|
private sealed class InMemoryTemplateRepository : StellaOps.Notify.Storage.Mongo.Repositories.INotifyTemplateRepository
|
|
{
|
|
private readonly Dictionary<string, NotifyTemplate> _templates = new();
|
|
|
|
public Task UpsertAsync(NotifyTemplate template, CancellationToken cancellationToken = default)
|
|
{
|
|
var key = $"{template.TenantId}:{template.TemplateId}";
|
|
_templates[key] = template;
|
|
return Task.CompletedTask;
|
|
}
|
|
|
|
public Task<NotifyTemplate?> GetAsync(string tenantId, string templateId, CancellationToken cancellationToken = default)
|
|
{
|
|
var key = $"{tenantId}:{templateId}";
|
|
return Task.FromResult(_templates.GetValueOrDefault(key));
|
|
}
|
|
|
|
public Task<IReadOnlyList<NotifyTemplate>> ListAsync(string tenantId, CancellationToken cancellationToken = default)
|
|
{
|
|
var result = _templates.Values
|
|
.Where(t => t.TenantId == tenantId)
|
|
.ToList();
|
|
return Task.FromResult<IReadOnlyList<NotifyTemplate>>(result);
|
|
}
|
|
|
|
public Task DeleteAsync(string tenantId, string templateId, CancellationToken cancellationToken = default)
|
|
{
|
|
var key = $"{tenantId}:{templateId}";
|
|
_templates.Remove(key);
|
|
return Task.CompletedTask;
|
|
}
|
|
}
|
|
|
|
private sealed class InMemoryAuditRepository : StellaOps.Notify.Storage.Mongo.Repositories.INotifyAuditRepository
|
|
{
|
|
public List<(string TenantId, string EventType, string Actor, IReadOnlyDictionary<string, string> Metadata)> Entries { get; } = [];
|
|
|
|
public Task AppendAsync(
|
|
string tenantId,
|
|
string eventType,
|
|
string actor,
|
|
IReadOnlyDictionary<string, string> metadata,
|
|
CancellationToken cancellationToken)
|
|
{
|
|
Entries.Add((tenantId, eventType, actor, metadata));
|
|
return Task.CompletedTask;
|
|
}
|
|
}
|
|
}
|