product advisories, stella router improval, tests streghthening
This commit is contained in:
@@ -0,0 +1,552 @@
|
||||
// -----------------------------------------------------------------------------
|
||||
// StellaOps.Notify.WebService.Tests / W1 / NotifyWebServiceAuthTests.cs
|
||||
// W1 auth tests for Notify.WebService (deny-by-default, token expiry, tenant isolation).
|
||||
// Task: NOTIFY-5100-013
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.IdentityModel.Tokens.Jwt;
|
||||
using System.Linq;
|
||||
using System.Net;
|
||||
using System.Net.Http;
|
||||
using System.Net.Http.Headers;
|
||||
using System.Security.Claims;
|
||||
using System.Text;
|
||||
using System.Text.Json.Nodes;
|
||||
using System.Threading.Tasks;
|
||||
using FluentAssertions;
|
||||
using Microsoft.AspNetCore.Mvc.Testing;
|
||||
using Microsoft.IdentityModel.Tokens;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Notify.WebService.Tests.W1;
|
||||
|
||||
/// <summary>
|
||||
/// W1 authentication and authorization tests for Notify WebService.
|
||||
/// Tests verify deny-by-default behavior, token validation, scope enforcement,
|
||||
/// and tenant isolation.
|
||||
/// </summary>
|
||||
public class NotifyWebServiceAuthTests : IClassFixture<WebApplicationFactory<Program>>, IAsyncLifetime
|
||||
{
|
||||
private const string SigningKey = "super-secret-test-key-for-auth-tests-1234567890";
|
||||
private const string Issuer = "test-issuer";
|
||||
private const string Audience = "notify";
|
||||
private const string TestTenantId = "tenant-auth-test";
|
||||
private const string OtherTenantId = "tenant-other";
|
||||
|
||||
private readonly WebApplicationFactory<Program> _factory;
|
||||
|
||||
public NotifyWebServiceAuthTests(WebApplicationFactory<Program> factory)
|
||||
{
|
||||
_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", "false"); // Deny by default
|
||||
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");
|
||||
});
|
||||
}
|
||||
|
||||
public Task InitializeAsync() => Task.CompletedTask;
|
||||
public Task DisposeAsync() => Task.CompletedTask;
|
||||
|
||||
#region Deny-by-Default
|
||||
|
||||
[Fact]
|
||||
public async Task NoToken_ApiEndpoint_Returns401()
|
||||
{
|
||||
// Arrange
|
||||
var client = _factory.CreateClient();
|
||||
// No Authorization header
|
||||
|
||||
// Act
|
||||
var response = await client.GetAsync("/api/v1/notify/rules");
|
||||
|
||||
// Assert
|
||||
response.StatusCode.Should().Be(HttpStatusCode.Unauthorized);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task NoToken_HealthEndpoint_Returns200()
|
||||
{
|
||||
// Arrange - health endpoints should be public
|
||||
var client = _factory.CreateClient();
|
||||
|
||||
// Act
|
||||
var response = await client.GetAsync("/healthz");
|
||||
|
||||
// Assert
|
||||
response.StatusCode.Should().Be(HttpStatusCode.OK);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task InvalidToken_Returns401()
|
||||
{
|
||||
// Arrange
|
||||
var client = _factory.CreateClient();
|
||||
client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", "invalid-token-here");
|
||||
|
||||
// Act
|
||||
var response = await client.GetAsync("/api/v1/notify/rules");
|
||||
|
||||
// Assert
|
||||
response.StatusCode.Should().Be(HttpStatusCode.Unauthorized);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task MalformedAuthHeader_Returns401()
|
||||
{
|
||||
// Arrange
|
||||
var client = _factory.CreateClient();
|
||||
client.DefaultRequestHeaders.TryAddWithoutValidation("Authorization", "NotBearer some-token");
|
||||
|
||||
// Act
|
||||
var response = await client.GetAsync("/api/v1/notify/rules");
|
||||
|
||||
// Assert
|
||||
response.StatusCode.Should().Be(HttpStatusCode.Unauthorized);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Token Expiry
|
||||
|
||||
[Fact]
|
||||
public async Task ExpiredToken_Returns401()
|
||||
{
|
||||
// Arrange
|
||||
var expiredToken = CreateToken(
|
||||
tenantId: TestTenantId,
|
||||
scopes: new[] { "notify.viewer" },
|
||||
expiresAt: DateTime.UtcNow.AddHours(-1)); // Already expired
|
||||
|
||||
var client = _factory.CreateClient();
|
||||
client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", expiredToken);
|
||||
|
||||
// Act
|
||||
var response = await client.GetAsync("/api/v1/notify/rules");
|
||||
|
||||
// Assert
|
||||
response.StatusCode.Should().Be(HttpStatusCode.Unauthorized);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task NotYetValidToken_Returns401()
|
||||
{
|
||||
// Arrange
|
||||
var notYetValidToken = CreateToken(
|
||||
tenantId: TestTenantId,
|
||||
scopes: new[] { "notify.viewer" },
|
||||
notBefore: DateTime.UtcNow.AddHours(1)); // Not valid yet
|
||||
|
||||
var client = _factory.CreateClient();
|
||||
client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", notYetValidToken);
|
||||
|
||||
// Act
|
||||
var response = await client.GetAsync("/api/v1/notify/rules");
|
||||
|
||||
// Assert
|
||||
response.StatusCode.Should().Be(HttpStatusCode.Unauthorized);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ValidToken_Returns200()
|
||||
{
|
||||
// Arrange
|
||||
var validToken = CreateToken(
|
||||
tenantId: TestTenantId,
|
||||
scopes: new[] { "notify.viewer" },
|
||||
expiresAt: DateTime.UtcNow.AddHours(1));
|
||||
|
||||
var client = _factory.CreateClient();
|
||||
client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", validToken);
|
||||
|
||||
// Act
|
||||
var response = await client.GetAsync("/api/v1/notify/rules");
|
||||
|
||||
// Assert
|
||||
response.StatusCode.Should().Be(HttpStatusCode.OK);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Scope Enforcement
|
||||
|
||||
[Fact]
|
||||
public async Task ViewerScope_CanRead_Rules()
|
||||
{
|
||||
// Arrange
|
||||
var viewerToken = CreateToken(TestTenantId, new[] { "notify.viewer" });
|
||||
var client = _factory.CreateClient();
|
||||
client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", viewerToken);
|
||||
|
||||
// Act
|
||||
var response = await client.GetAsync("/api/v1/notify/rules");
|
||||
|
||||
// Assert
|
||||
response.StatusCode.Should().Be(HttpStatusCode.OK);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ViewerScope_CannotCreate_Rules()
|
||||
{
|
||||
// Arrange
|
||||
var viewerToken = CreateToken(TestTenantId, new[] { "notify.viewer" });
|
||||
var client = _factory.CreateClient();
|
||||
client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", viewerToken);
|
||||
|
||||
var payload = CreateRulePayload($"rule-viewer-{Guid.NewGuid():N}");
|
||||
|
||||
// Act
|
||||
var response = await client.PostAsync(
|
||||
"/api/v1/notify/rules",
|
||||
new StringContent(payload.ToJsonString(), Encoding.UTF8, "application/json"));
|
||||
|
||||
// Assert
|
||||
response.StatusCode.Should().Be(HttpStatusCode.Forbidden);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task OperatorScope_CanCreate_Rules()
|
||||
{
|
||||
// Arrange
|
||||
var operatorToken = CreateToken(TestTenantId, new[] { "notify.viewer", "notify.operator" });
|
||||
var client = _factory.CreateClient();
|
||||
client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", operatorToken);
|
||||
|
||||
var payload = CreateRulePayload($"rule-operator-{Guid.NewGuid():N}");
|
||||
|
||||
// Act
|
||||
var response = await client.PostAsync(
|
||||
"/api/v1/notify/rules",
|
||||
new StringContent(payload.ToJsonString(), Encoding.UTF8, "application/json"));
|
||||
|
||||
// Assert
|
||||
response.StatusCode.Should().Be(HttpStatusCode.Created);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task OperatorScope_CanDelete_Rules()
|
||||
{
|
||||
// Arrange
|
||||
var operatorToken = CreateToken(TestTenantId, new[] { "notify.viewer", "notify.operator" });
|
||||
var client = _factory.CreateClient();
|
||||
client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", operatorToken);
|
||||
|
||||
// First create a rule
|
||||
var ruleId = $"rule-delete-{Guid.NewGuid():N}";
|
||||
var payload = CreateRulePayload(ruleId);
|
||||
await client.PostAsync(
|
||||
"/api/v1/notify/rules",
|
||||
new StringContent(payload.ToJsonString(), Encoding.UTF8, "application/json"));
|
||||
|
||||
// Act
|
||||
var response = await client.DeleteAsync($"/api/v1/notify/rules/{ruleId}");
|
||||
|
||||
// Assert
|
||||
response.StatusCode.Should().Be(HttpStatusCode.NoContent);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task NoRequiredScope_Returns403()
|
||||
{
|
||||
// Arrange - token with unrelated scope
|
||||
var wrongScopeToken = CreateToken(TestTenantId, new[] { "some.other.scope" });
|
||||
var client = _factory.CreateClient();
|
||||
client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", wrongScopeToken);
|
||||
|
||||
// Act
|
||||
var response = await client.GetAsync("/api/v1/notify/rules");
|
||||
|
||||
// Assert
|
||||
response.StatusCode.Should().Be(HttpStatusCode.Forbidden);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task AdminScope_CanAccessInternalEndpoints()
|
||||
{
|
||||
// Arrange
|
||||
var adminToken = CreateToken(TestTenantId, new[] { "notify.viewer", "notify.operator", "notify.admin" });
|
||||
var client = _factory.CreateClient();
|
||||
client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", adminToken);
|
||||
|
||||
var payload = CreateRulePayload("rule-internal-test");
|
||||
|
||||
// Act - internal normalize endpoint
|
||||
var response = await client.PostAsync(
|
||||
"/api/v1/notify/_internal/rules/normalize",
|
||||
new StringContent(payload.ToJsonString(), Encoding.UTF8, "application/json"));
|
||||
|
||||
// Assert
|
||||
response.StatusCode.Should().Be(HttpStatusCode.OK);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Tenant Isolation
|
||||
|
||||
[Fact]
|
||||
public async Task CreateRule_UsesTokenTenantId()
|
||||
{
|
||||
// Arrange
|
||||
var operatorToken = CreateToken(TestTenantId, new[] { "notify.viewer", "notify.operator" });
|
||||
var client = _factory.CreateClient();
|
||||
client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", operatorToken);
|
||||
|
||||
var ruleId = $"rule-tenant-{Guid.NewGuid():N}";
|
||||
var payload = CreateRulePayload(ruleId);
|
||||
// Payload has a tenantId, but should be overridden by token
|
||||
|
||||
// Act
|
||||
await client.PostAsync(
|
||||
"/api/v1/notify/rules",
|
||||
new StringContent(payload.ToJsonString(), Encoding.UTF8, "application/json"));
|
||||
|
||||
// Get the rule back
|
||||
var response = await client.GetAsync($"/api/v1/notify/rules/{ruleId}");
|
||||
var content = await response.Content.ReadAsStringAsync();
|
||||
var json = JsonNode.Parse(content);
|
||||
|
||||
// Assert - tenantId should match token, not payload
|
||||
json?["tenantId"]?.GetValue<string>().Should().Be(TestTenantId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ListRules_OnlyReturnsSameTenant()
|
||||
{
|
||||
// Arrange - create rules with two different tenants
|
||||
var tenant1Token = CreateToken(TestTenantId, new[] { "notify.viewer", "notify.operator" });
|
||||
var tenant2Token = CreateToken(OtherTenantId, new[] { "notify.viewer", "notify.operator" });
|
||||
|
||||
var client1 = _factory.CreateClient();
|
||||
client1.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", tenant1Token);
|
||||
|
||||
var client2 = _factory.CreateClient();
|
||||
client2.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", tenant2Token);
|
||||
|
||||
// Create rule for tenant 1
|
||||
var rule1Id = $"rule-t1-{Guid.NewGuid():N}";
|
||||
await client1.PostAsync(
|
||||
"/api/v1/notify/rules",
|
||||
new StringContent(CreateRulePayload(rule1Id).ToJsonString(), Encoding.UTF8, "application/json"));
|
||||
|
||||
// Create rule for tenant 2
|
||||
var rule2Id = $"rule-t2-{Guid.NewGuid():N}";
|
||||
await client2.PostAsync(
|
||||
"/api/v1/notify/rules",
|
||||
new StringContent(CreateRulePayload(rule2Id).ToJsonString(), Encoding.UTF8, "application/json"));
|
||||
|
||||
// Act - tenant 1 lists rules
|
||||
var response1 = await client1.GetAsync("/api/v1/notify/rules");
|
||||
var content1 = await response1.Content.ReadAsStringAsync();
|
||||
var rules1 = JsonNode.Parse(content1)?.AsArray();
|
||||
|
||||
// Act - tenant 2 lists rules
|
||||
var response2 = await client2.GetAsync("/api/v1/notify/rules");
|
||||
var content2 = await response2.Content.ReadAsStringAsync();
|
||||
var rules2 = JsonNode.Parse(content2)?.AsArray();
|
||||
|
||||
// Assert - each tenant only sees their own rules
|
||||
rules1?.Any(r => r?["ruleId"]?.GetValue<string>() == rule1Id).Should().BeTrue();
|
||||
rules1?.Any(r => r?["ruleId"]?.GetValue<string>() == rule2Id).Should().BeFalse();
|
||||
|
||||
rules2?.Any(r => r?["ruleId"]?.GetValue<string>() == rule2Id).Should().BeTrue();
|
||||
rules2?.Any(r => r?["ruleId"]?.GetValue<string>() == rule1Id).Should().BeFalse();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetRule_DifferentTenant_Returns404()
|
||||
{
|
||||
// Arrange - create rule with tenant 1
|
||||
var tenant1Token = CreateToken(TestTenantId, new[] { "notify.viewer", "notify.operator" });
|
||||
var tenant2Token = CreateToken(OtherTenantId, new[] { "notify.viewer" });
|
||||
|
||||
var client1 = _factory.CreateClient();
|
||||
client1.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", tenant1Token);
|
||||
|
||||
var ruleId = $"rule-cross-tenant-{Guid.NewGuid():N}";
|
||||
await client1.PostAsync(
|
||||
"/api/v1/notify/rules",
|
||||
new StringContent(CreateRulePayload(ruleId).ToJsonString(), Encoding.UTF8, "application/json"));
|
||||
|
||||
// Act - tenant 2 tries to get tenant 1's rule
|
||||
var client2 = _factory.CreateClient();
|
||||
client2.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", tenant2Token);
|
||||
var response = await client2.GetAsync($"/api/v1/notify/rules/{ruleId}");
|
||||
|
||||
// Assert - should be 404, not 403 (don't leak existence)
|
||||
response.StatusCode.Should().Be(HttpStatusCode.NotFound);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task DeleteRule_DifferentTenant_Returns404()
|
||||
{
|
||||
// Arrange - create rule with tenant 1
|
||||
var tenant1Token = CreateToken(TestTenantId, new[] { "notify.viewer", "notify.operator" });
|
||||
var tenant2Token = CreateToken(OtherTenantId, new[] { "notify.viewer", "notify.operator" });
|
||||
|
||||
var client1 = _factory.CreateClient();
|
||||
client1.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", tenant1Token);
|
||||
|
||||
var ruleId = $"rule-cross-delete-{Guid.NewGuid():N}";
|
||||
await client1.PostAsync(
|
||||
"/api/v1/notify/rules",
|
||||
new StringContent(CreateRulePayload(ruleId).ToJsonString(), Encoding.UTF8, "application/json"));
|
||||
|
||||
// Act - tenant 2 tries to delete tenant 1's rule
|
||||
var client2 = _factory.CreateClient();
|
||||
client2.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", tenant2Token);
|
||||
var response = await client2.DeleteAsync($"/api/v1/notify/rules/{ruleId}");
|
||||
|
||||
// Assert - should be 404, not 204 (don't leak existence)
|
||||
response.StatusCode.Should().Be(HttpStatusCode.NotFound);
|
||||
|
||||
// Verify rule still exists for tenant 1
|
||||
var verifyResponse = await client1.GetAsync($"/api/v1/notify/rules/{ruleId}");
|
||||
verifyResponse.StatusCode.Should().Be(HttpStatusCode.OK);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Wrong Issuer/Audience
|
||||
|
||||
[Fact]
|
||||
public async Task WrongIssuer_Returns401()
|
||||
{
|
||||
// Arrange
|
||||
var wrongIssuerToken = CreateTokenWithConfig(
|
||||
tenantId: TestTenantId,
|
||||
scopes: new[] { "notify.viewer" },
|
||||
issuer: "wrong-issuer",
|
||||
audience: Audience,
|
||||
signingKey: SigningKey);
|
||||
|
||||
var client = _factory.CreateClient();
|
||||
client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", wrongIssuerToken);
|
||||
|
||||
// Act
|
||||
var response = await client.GetAsync("/api/v1/notify/rules");
|
||||
|
||||
// Assert
|
||||
response.StatusCode.Should().Be(HttpStatusCode.Unauthorized);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task WrongAudience_Returns401()
|
||||
{
|
||||
// Arrange
|
||||
var wrongAudienceToken = CreateTokenWithConfig(
|
||||
tenantId: TestTenantId,
|
||||
scopes: new[] { "notify.viewer" },
|
||||
issuer: Issuer,
|
||||
audience: "wrong-audience",
|
||||
signingKey: SigningKey);
|
||||
|
||||
var client = _factory.CreateClient();
|
||||
client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", wrongAudienceToken);
|
||||
|
||||
// Act
|
||||
var response = await client.GetAsync("/api/v1/notify/rules");
|
||||
|
||||
// Assert
|
||||
response.StatusCode.Should().Be(HttpStatusCode.Unauthorized);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task WrongSigningKey_Returns401()
|
||||
{
|
||||
// Arrange
|
||||
var wrongKeyToken = CreateTokenWithConfig(
|
||||
tenantId: TestTenantId,
|
||||
scopes: new[] { "notify.viewer" },
|
||||
issuer: Issuer,
|
||||
audience: Audience,
|
||||
signingKey: "different-signing-key-that-is-long-enough-for-hmac");
|
||||
|
||||
var client = _factory.CreateClient();
|
||||
client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", wrongKeyToken);
|
||||
|
||||
// Act
|
||||
var response = await client.GetAsync("/api/v1/notify/rules");
|
||||
|
||||
// Assert
|
||||
response.StatusCode.Should().Be(HttpStatusCode.Unauthorized);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Helper Methods
|
||||
|
||||
private static string CreateToken(
|
||||
string tenantId,
|
||||
string[] scopes,
|
||||
DateTime? expiresAt = null,
|
||||
DateTime? notBefore = null)
|
||||
{
|
||||
return CreateTokenWithConfig(tenantId, scopes, Issuer, Audience, SigningKey, expiresAt, notBefore);
|
||||
}
|
||||
|
||||
private static string CreateTokenWithConfig(
|
||||
string tenantId,
|
||||
string[] scopes,
|
||||
string issuer,
|
||||
string audience,
|
||||
string signingKey,
|
||||
DateTime? expiresAt = null,
|
||||
DateTime? notBefore = null)
|
||||
{
|
||||
var handler = new JwtSecurityTokenHandler();
|
||||
var key = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(signingKey));
|
||||
var credentials = new SigningCredentials(key, SecurityAlgorithms.HmacSha256);
|
||||
|
||||
var claims = new List<Claim>
|
||||
{
|
||||
new(JwtRegisteredClaimNames.Sub, "test-user"),
|
||||
new(JwtRegisteredClaimNames.Jti, Guid.NewGuid().ToString()),
|
||||
new("tenant_id", tenantId)
|
||||
};
|
||||
claims.AddRange(scopes.Select(s => new Claim("scope", s)));
|
||||
|
||||
var token = new JwtSecurityToken(
|
||||
issuer: issuer,
|
||||
audience: audience,
|
||||
claims: claims,
|
||||
notBefore: notBefore,
|
||||
expires: expiresAt ?? DateTime.UtcNow.AddHours(1),
|
||||
signingCredentials: credentials);
|
||||
|
||||
return handler.WriteToken(token);
|
||||
}
|
||||
|
||||
private static JsonObject CreateRulePayload(string ruleId)
|
||||
{
|
||||
return new JsonObject
|
||||
{
|
||||
["schemaVersion"] = "notify-rule@1",
|
||||
["ruleId"] = ruleId,
|
||||
["tenantId"] = TestTenantId,
|
||||
["name"] = $"Test Rule {ruleId}",
|
||||
["description"] = "Auth test rule",
|
||||
["enabled"] = true,
|
||||
["eventKinds"] = new JsonArray { "scan.completed" },
|
||||
["actions"] = new JsonArray
|
||||
{
|
||||
new JsonObject
|
||||
{
|
||||
["actionId"] = $"action-{Guid.NewGuid():N}",
|
||||
["channel"] = "email:test",
|
||||
["templateKey"] = "default"
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
#endregion
|
||||
}
|
||||
Reference in New Issue
Block a user