product advisories, stella router improval, tests streghthening

This commit is contained in:
StellaOps Bot
2025-12-24 14:20:26 +02:00
parent 5540ce9430
commit 2c2bbf1005
171 changed files with 58943 additions and 135 deletions

View File

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