Add unit tests and implementations for MongoDB index models and OpenAPI metadata
- Implemented `MongoIndexModelTests` to verify index models for various stores. - Created `OpenApiMetadataFactory` with methods to generate OpenAPI metadata. - Added tests for `OpenApiMetadataFactory` to ensure expected defaults and URL overrides. - Introduced `ObserverSurfaceSecrets` and `WebhookSurfaceSecrets` for managing secrets. - Developed `RuntimeSurfaceFsClient` and `WebhookSurfaceFsClient` for manifest retrieval. - Added dependency injection tests for `SurfaceEnvironmentRegistration` in both Observer and Webhook contexts. - Implemented tests for secret resolution in `ObserverSurfaceSecretsTests` and `WebhookSurfaceSecretsTests`. - Created `EnsureLinkNotMergeCollectionsMigrationTests` to validate MongoDB migration logic. - Added project files for MongoDB tests and NuGet package mirroring.
This commit is contained in:
@@ -0,0 +1,77 @@
|
||||
using System.Text.Json;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Notifier.Tests;
|
||||
|
||||
public sealed class AttestationTemplateCoverageTests
|
||||
{
|
||||
private static readonly string RepoRoot = LocateRepoRoot();
|
||||
|
||||
[Fact]
|
||||
public void Attestation_templates_cover_required_channels()
|
||||
{
|
||||
var directory = Path.Combine(RepoRoot, "offline", "notifier", "templates", "attestation");
|
||||
Assert.True(Directory.Exists(directory), $"Expected template directory at {directory}");
|
||||
|
||||
var templates = Directory
|
||||
.GetFiles(directory, "*.template.json")
|
||||
.Select(path => new
|
||||
{
|
||||
Path = path,
|
||||
Document = JsonDocument.Parse(File.ReadAllText(path)).RootElement
|
||||
})
|
||||
.ToList();
|
||||
|
||||
var required = new Dictionary<string, string[]>
|
||||
{
|
||||
["tmpl-attest-verify-fail"] = new[] { "slack", "email", "webhook" },
|
||||
["tmpl-attest-expiry-warning"] = new[] { "email", "slack" },
|
||||
["tmpl-attest-key-rotation"] = new[] { "email", "webhook" },
|
||||
["tmpl-attest-transparency-anomaly"] = new[] { "slack", "webhook" }
|
||||
};
|
||||
|
||||
foreach (var pair in required)
|
||||
{
|
||||
var matches = templates.Where(t => t.Document.GetProperty("key").GetString() == pair.Key);
|
||||
var channels = matches
|
||||
.Select(t => t.Document.GetProperty("channelType").GetString() ?? string.Empty)
|
||||
.ToHashSet(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
var missing = pair.Value.Where(requiredChannel => !channels.Contains(requiredChannel)).ToArray();
|
||||
Assert.True(missing.Length == 0, $"{pair.Key} missing channels: {string.Join(", ", missing)}");
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Attestation_templates_include_schema_and_locale_metadata()
|
||||
{
|
||||
var directory = Path.Combine(RepoRoot, "offline", "notifier", "templates", "attestation");
|
||||
Assert.True(Directory.Exists(directory), $"Expected template directory at {directory}");
|
||||
|
||||
foreach (var path in Directory.GetFiles(directory, "*.template.json"))
|
||||
{
|
||||
var document = JsonDocument.Parse(File.ReadAllText(path)).RootElement;
|
||||
|
||||
Assert.True(document.TryGetProperty("schemaVersion", out var schemaVersion) && !string.IsNullOrWhiteSpace(schemaVersion.GetString()), $"schemaVersion missing for {Path.GetFileName(path)}");
|
||||
Assert.True(document.TryGetProperty("locale", out var locale) && !string.IsNullOrWhiteSpace(locale.GetString()), $"locale missing for {Path.GetFileName(path)}");
|
||||
Assert.True(document.TryGetProperty("key", out var key) && !string.IsNullOrWhiteSpace(key.GetString()), $"key missing for {Path.GetFileName(path)}");
|
||||
}
|
||||
}
|
||||
|
||||
private static string LocateRepoRoot()
|
||||
{
|
||||
var directory = AppContext.BaseDirectory;
|
||||
while (directory != null)
|
||||
{
|
||||
var candidate = Path.Combine(directory, "offline", "notifier", "templates", "attestation");
|
||||
if (Directory.Exists(candidate))
|
||||
{
|
||||
return directory;
|
||||
}
|
||||
|
||||
directory = Directory.GetParent(directory)?.FullName;
|
||||
}
|
||||
|
||||
throw new InvalidOperationException("Unable to locate repository root containing offline/notifier/templates/attestation.");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
using System.Text.Json;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Notifier.Tests;
|
||||
|
||||
public sealed class DeprecationTemplateTests
|
||||
{
|
||||
[Fact]
|
||||
public void Deprecation_templates_cover_slack_and_email()
|
||||
{
|
||||
var directory = LocateOfflineDeprecationDir();
|
||||
Assert.True(Directory.Exists(directory), $"Expected template directory at {directory}");
|
||||
|
||||
var templates = Directory
|
||||
.GetFiles(directory, "*.template.json")
|
||||
.Select(path => new
|
||||
{
|
||||
Path = path,
|
||||
Document = JsonDocument.Parse(File.ReadAllText(path)).RootElement
|
||||
})
|
||||
.ToList();
|
||||
|
||||
var channels = templates
|
||||
.Where(t => t.Document.GetProperty("key").GetString() == "tmpl-api-deprecation")
|
||||
.Select(t => t.Document.GetProperty("channelType").GetString() ?? string.Empty)
|
||||
.ToHashSet(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
Assert.Contains("slack", channels);
|
||||
Assert.Contains("email", channels);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Deprecation_templates_require_core_metadata()
|
||||
{
|
||||
var directory = LocateOfflineDeprecationDir();
|
||||
Assert.True(Directory.Exists(directory), $"Expected template directory at {directory}");
|
||||
|
||||
foreach (var path in Directory.GetFiles(directory, "*.template.json"))
|
||||
{
|
||||
var document = JsonDocument.Parse(File.ReadAllText(path)).RootElement;
|
||||
|
||||
Assert.True(document.TryGetProperty("metadata", out var meta), $"metadata missing for {Path.GetFileName(path)}");
|
||||
|
||||
// Ensure documented metadata keys are present for offline baseline.
|
||||
Assert.True(meta.TryGetProperty("version", out _), $"metadata.version missing for {Path.GetFileName(path)}");
|
||||
Assert.True(meta.TryGetProperty("author", out _), $"metadata.author missing for {Path.GetFileName(path)}");
|
||||
}
|
||||
}
|
||||
|
||||
private static string LocateOfflineDeprecationDir()
|
||||
{
|
||||
var directory = AppContext.BaseDirectory;
|
||||
while (directory != null)
|
||||
{
|
||||
var candidate = Path.Combine(directory, "offline", "notifier", "templates", "deprecation");
|
||||
if (Directory.Exists(candidate))
|
||||
{
|
||||
return candidate;
|
||||
}
|
||||
|
||||
directory = Directory.GetParent(directory)?.FullName;
|
||||
}
|
||||
|
||||
throw new InvalidOperationException("Unable to locate offline/notifier/templates/deprecation directory.");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
using System.Net;
|
||||
using Microsoft.AspNetCore.Mvc.Testing;
|
||||
using StellaOps.Notifier.WebService;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Notifier.Tests;
|
||||
|
||||
public sealed class OpenApiEndpointTests : IClassFixture<WebApplicationFactory<WebServiceAssemblyMarker>>
|
||||
{
|
||||
private readonly HttpClient _client;
|
||||
private readonly InMemoryPackApprovalRepository _packRepo = new();
|
||||
private readonly InMemoryLockRepository _lockRepo = new();
|
||||
private readonly InMemoryAuditRepository _auditRepo = new();
|
||||
|
||||
public OpenApiEndpointTests(WebApplicationFactory<WebServiceAssemblyMarker> factory)
|
||||
{
|
||||
_client = factory
|
||||
.WithWebHostBuilder(builder =>
|
||||
{
|
||||
builder.ConfigureServices(services =>
|
||||
{
|
||||
services.AddSingleton<INotifyPackApprovalRepository>(_packRepo);
|
||||
services.AddSingleton<INotifyLockRepository>(_lockRepo);
|
||||
services.AddSingleton<INotifyAuditRepository>(_auditRepo);
|
||||
});
|
||||
})
|
||||
.CreateClient();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task OpenApi_endpoint_serves_yaml_with_scope_header()
|
||||
{
|
||||
var response = await _client.GetAsync("/.well-known/openapi", TestContext.Current.CancellationToken);
|
||||
|
||||
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
|
||||
Assert.Equal("application/yaml", response.Content.Headers.ContentType?.MediaType);
|
||||
Assert.True(response.Headers.TryGetValues("X-OpenAPI-Scope", out var values) &&
|
||||
values.Contains("notify"));
|
||||
Assert.True(response.Headers.ETag is not null && response.Headers.ETag.Tag.Length > 2);
|
||||
|
||||
var body = await response.Content.ReadAsStringAsync(TestContext.Current.CancellationToken);
|
||||
Assert.Contains("openapi: 3.1.0", body);
|
||||
Assert.Contains("/api/v1/notify/quiet-hours", body);
|
||||
Assert.Contains("/api/v1/notify/incidents", body);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Deprecation_headers_emitted_for_api_surface()
|
||||
{
|
||||
var response = await _client.GetAsync("/api/v1/notify/rules", TestContext.Current.CancellationToken);
|
||||
|
||||
Assert.True(response.Headers.TryGetValues("Deprecation", out var depValues) &&
|
||||
depValues.Contains("true"));
|
||||
Assert.True(response.Headers.TryGetValues("Sunset", out var sunsetValues) &&
|
||||
sunsetValues.Any());
|
||||
Assert.True(response.Headers.TryGetValues("Link", out var linkValues) &&
|
||||
linkValues.Any(v => v.Contains("rel=\"deprecation\"")));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task PackApprovals_endpoint_validates_missing_headers()
|
||||
{
|
||||
var content = new StringContent("""{"eventId":"00000000-0000-0000-0000-000000000001","issuedAt":"2025-11-17T16:00:00Z","kind":"pack.approval.granted","packId":"offline-kit","decision":"approved","actor":"task-runner"}""", Encoding.UTF8, "application/json");
|
||||
var response = await _client.PostAsync("/api/v1/notify/pack-approvals", content, TestContext.Current.CancellationToken);
|
||||
|
||||
Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task PackApprovals_endpoint_accepts_happy_path_and_echoes_resume_token()
|
||||
{
|
||||
var content = new StringContent("""{"eventId":"00000000-0000-0000-0000-000000000002","issuedAt":"2025-11-17T16:00:00Z","kind":"pack.approval.granted","packId":"offline-kit","decision":"approved","actor":"task-runner","resumeToken":"rt-ok"}""", Encoding.UTF8, "application/json");
|
||||
var request = new HttpRequestMessage(HttpMethod.Post, "/api/v1/notify/pack-approvals")
|
||||
{
|
||||
Content = content
|
||||
};
|
||||
request.Headers.Add("X-StellaOps-Tenant", "tenant-a");
|
||||
request.Headers.Add("Idempotency-Key", Guid.NewGuid().ToString());
|
||||
|
||||
var response = await _client.SendAsync(request, TestContext.Current.CancellationToken);
|
||||
|
||||
Assert.Equal(HttpStatusCode.Accepted, response.StatusCode);
|
||||
Assert.True(response.Headers.TryGetValues("X-Resume-After", out var resumeValues) &&
|
||||
resumeValues.Contains("rt-ok"));
|
||||
Assert.True(_packRepo.Exists("tenant-a", Guid.Parse("00000000-0000-0000-0000-000000000002"), "offline-kit"));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
using StellaOps.Notify.Storage.Mongo.Documents;
|
||||
using StellaOps.Notify.Storage.Mongo.Repositories;
|
||||
|
||||
namespace StellaOps.Notifier.Tests.Support;
|
||||
|
||||
internal sealed class InMemoryAuditRepository : INotifyAuditRepository
|
||||
{
|
||||
private readonly List<NotifyAuditEntryDocument> _entries = new();
|
||||
|
||||
public Task AppendAsync(NotifyAuditEntryDocument entry, CancellationToken cancellationToken = default)
|
||||
{
|
||||
_entries.Add(entry);
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
public Task<IReadOnlyList<NotifyAuditEntryDocument>> QueryAsync(string tenantId, DateTimeOffset? since, int? limit, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var items = _entries
|
||||
.Where(e => e.TenantId == tenantId && (!since.HasValue || e.Timestamp >= since.Value))
|
||||
.OrderByDescending(e => e.Timestamp)
|
||||
.ToList();
|
||||
|
||||
if (limit is > 0)
|
||||
{
|
||||
items = items.Take(limit.Value).ToList();
|
||||
}
|
||||
|
||||
return Task.FromResult<IReadOnlyList<NotifyAuditEntryDocument>>(items);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
using StellaOps.Notify.Storage.Mongo.Documents;
|
||||
using StellaOps.Notify.Storage.Mongo.Repositories;
|
||||
|
||||
namespace StellaOps.Notifier.Tests.Support;
|
||||
|
||||
internal sealed class InMemoryPackApprovalRepository : INotifyPackApprovalRepository
|
||||
{
|
||||
private readonly Dictionary<(string TenantId, Guid EventId, string PackId), PackApprovalDocument> _records = new();
|
||||
|
||||
public Task UpsertAsync(PackApprovalDocument document, CancellationToken cancellationToken = default)
|
||||
{
|
||||
_records[(document.TenantId, document.EventId, document.PackId)] = document;
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
public bool Exists(string tenantId, Guid eventId, string packId)
|
||||
=> _records.ContainsKey((tenantId, eventId, packId));
|
||||
}
|
||||
Reference in New Issue
Block a user