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

@@ -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");
}
}