// ----------------------------------------------------------------------------- // 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; /// /// W1 authentication and authorization tests for Notify WebService. /// Tests verify deny-by-default behavior, token validation, scope enforcement, /// and tenant isolation. /// public class NotifyWebServiceAuthTests : IClassFixture>, 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 _factory; public NotifyWebServiceAuthTests(WebApplicationFactory 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().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() == rule1Id).Should().BeTrue(); rules1?.Any(r => r?["ruleId"]?.GetValue() == rule2Id).Should().BeFalse(); rules2?.Any(r => r?["ruleId"]?.GetValue() == rule2Id).Should().BeTrue(); rules2?.Any(r => r?["ruleId"]?.GetValue() == 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 { 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 }