Files
git.stella-ops.org/src/Notifier/StellaOps.Notifier/StellaOps.Notifier.Tests/Templates/NotifyTemplateServiceTests.cs
StellaOps Bot ef6e4b2067
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
Merge branch 'main' of https://git.stella-ops.org/stella-ops.org/git.stella-ops.org
2025-11-27 21:45:32 +02:00

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;
}
}
}