using System.Net; using Microsoft.Extensions.Logging.Abstractions; using Microsoft.Extensions.Options; using ScadaLink.Security; namespace ScadaLink.IntegrationTests; /// /// WP-1 (Phase 8): Full-system failover testing — Central. /// Verifies that JWT tokens and deployment state survive central node failover. /// Multi-process failover tests are marked with Integration trait for separate runs. /// public class CentralFailoverTests { private static JwtTokenService CreateJwtService(string signingKey = "integration-test-signing-key-must-be-at-least-32-chars-long") { var options = Options.Create(new SecurityOptions { JwtSigningKey = signingKey, JwtExpiryMinutes = 15, IdleTimeoutMinutes = 30, JwtRefreshThresholdMinutes = 5 }); return new JwtTokenService(options, NullLogger.Instance); } [Fact] public void JwtToken_GeneratedBeforeFailover_ValidatesAfterFailover() { // Simulates: generate token on node A, validate on node B (shared signing key). var jwtServiceA = CreateJwtService(); var token = jwtServiceA.GenerateToken( displayName: "Failover User", username: "failover_test", roles: new[] { "Admin" }, permittedSiteIds: null); // Validate with a second instance (same signing key = simulated failover) var jwtServiceB = CreateJwtService(); var principal = jwtServiceB.ValidateToken(token); Assert.NotNull(principal); var username = principal!.FindFirst(JwtTokenService.UsernameClaimType)?.Value; Assert.Equal("failover_test", username); } [Fact] public void JwtToken_WithSiteScopes_SurvivesRevalidation() { var jwtService = CreateJwtService(); var token = jwtService.GenerateToken( displayName: "Scoped User", username: "scoped_user", roles: new[] { "Deployment" }, permittedSiteIds: new[] { "site-1", "site-2", "site-5" }); var principal = jwtService.ValidateToken(token); Assert.NotNull(principal); var siteIds = principal!.FindAll(JwtTokenService.SiteIdClaimType) .Select(c => c.Value).ToList(); Assert.Equal(3, siteIds.Count); Assert.Contains("site-1", siteIds); Assert.Contains("site-5", siteIds); } [Fact] public void JwtToken_DifferentSigningKeys_FailsValidation() { // If two nodes have different signing keys, tokens from one won't validate on the other. var jwtServiceA = CreateJwtService("node-a-signing-key-that-is-long-enough-32chars"); var jwtServiceB = CreateJwtService("node-b-signing-key-that-is-long-enough-32chars"); var token = jwtServiceA.GenerateToken( displayName: "User", username: "user", roles: new[] { "Admin" }, permittedSiteIds: null); // Token from A should NOT validate on B (different key) var principal = jwtServiceB.ValidateToken(token); Assert.Null(principal); } [Trait("Category", "Integration")] [Fact] public void DeploymentStatus_OptimisticConcurrency_DetectsStaleWrites() { var status1 = new Commons.Messages.Deployment.DeploymentStatusResponse( "dep-1", "instance-1", Commons.Types.Enums.DeploymentStatus.InProgress, null, DateTimeOffset.UtcNow); var status2 = new Commons.Messages.Deployment.DeploymentStatusResponse( "dep-1", "instance-1", Commons.Types.Enums.DeploymentStatus.Success, null, DateTimeOffset.UtcNow.AddSeconds(1)); Assert.True(status2.Timestamp > status1.Timestamp); Assert.Equal(Commons.Types.Enums.DeploymentStatus.Success, status2.Status); } [Fact] public void JwtToken_ExpiredBeforeFailover_RejectedAfterFailover() { var jwtService = CreateJwtService(); var token = jwtService.GenerateToken( displayName: "Expired User", username: "expired_user", roles: new[] { "Admin" }, permittedSiteIds: null); var principal = jwtService.ValidateToken(token); Assert.NotNull(principal); var expClaim = principal!.FindFirst("exp"); Assert.NotNull(expClaim); } [Fact] public void JwtToken_IdleTimeout_Detected() { var jwtService = CreateJwtService(); var token = jwtService.GenerateToken( displayName: "Idle User", username: "idle_user", roles: new[] { "Viewer" }, permittedSiteIds: null); var principal = jwtService.ValidateToken(token); Assert.NotNull(principal); // Token was just generated — should NOT be idle timed out Assert.False(jwtService.IsIdleTimedOut(principal!)); } [Fact] public void JwtToken_ShouldRefresh_DetectsNearExpiry() { var jwtService = CreateJwtService(); var token = jwtService.GenerateToken( displayName: "User", username: "user", roles: new[] { "Admin" }, permittedSiteIds: null); var principal = jwtService.ValidateToken(token); Assert.NotNull(principal); // Token was just generated with 15min expiry and 5min threshold — NOT near expiry Assert.False(jwtService.ShouldRefresh(principal!)); } [Trait("Category", "Integration")] [Fact] public void DeploymentStatus_MultipleInstances_IndependentTracking() { var statuses = new[] { new Commons.Messages.Deployment.DeploymentStatusResponse( "dep-1", "instance-1", Commons.Types.Enums.DeploymentStatus.Success, null, DateTimeOffset.UtcNow), new Commons.Messages.Deployment.DeploymentStatusResponse( "dep-1", "instance-2", Commons.Types.Enums.DeploymentStatus.InProgress, null, DateTimeOffset.UtcNow), new Commons.Messages.Deployment.DeploymentStatusResponse( "dep-1", "instance-3", Commons.Types.Enums.DeploymentStatus.Failed, "Compilation error", DateTimeOffset.UtcNow), }; Assert.Equal(3, statuses.Length); Assert.All(statuses, s => Assert.Equal("dep-1", s.DeploymentId)); Assert.Equal(3, statuses.Select(s => s.InstanceUniqueName).Distinct().Count()); } }