Merge feat/site-node-health: serve health on site nodes + shared active-node check (Health 0.3.0)
This commit is contained in:
+3
-1
@@ -42,7 +42,9 @@ public class MonitoringAuthorizationTests
|
||||
public void HealthDashboard_IsIntentionallyAllAuthenticatedRoles()
|
||||
{
|
||||
// Health Dashboard stays all-roles (no policy) per the design doc.
|
||||
var attr = AuthorizeOf<Health>();
|
||||
// Fully qualified: the Communication.Health namespace otherwise makes
|
||||
// the bare `Health` page type ambiguous.
|
||||
var attr = AuthorizeOf<CentralUI.Components.Pages.Monitoring.Health>();
|
||||
|
||||
Assert.NotNull(attr);
|
||||
Assert.Null(attr!.Policy);
|
||||
|
||||
@@ -5,6 +5,7 @@ using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Diagnostics.HealthChecks;
|
||||
using Microsoft.Extensions.Options;
|
||||
using ZB.MOM.WW.Health;
|
||||
using ZB.MOM.WW.Health.Akka;
|
||||
using ZB.MOM.WW.ScadaBridge.Host.Health;
|
||||
|
||||
namespace ZB.MOM.WW.ScadaBridge.Host.Tests;
|
||||
@@ -207,11 +208,13 @@ public class HealthCheckTests : IDisposable
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ActiveNodeCheck_IsHostLocalOldestNodeCheck()
|
||||
public void ActiveNodeCheck_IsTheSharedOldestUpMemberCheck()
|
||||
{
|
||||
// The shared package's ActiveNodeHealthCheck is LEADER-based (review 01 [High]):
|
||||
// during a partition both nodes think they lead and Traefik serves both. The
|
||||
// active-node check must be the host-local oldest-member check instead.
|
||||
// The property under test is that the active tier selects by AGE, not by leadership: during a
|
||||
// partition both sides think they lead and Traefik would serve both (review 01 [High]).
|
||||
// Through ZB.MOM.WW.Health 0.2.1 the shared check was leader-based, so this host carried a
|
||||
// private OldestNodeActiveHealthCheck; 0.3.0 made the shared check age-based, so the private
|
||||
// copy is gone and the shared type is now the correct answer here.
|
||||
var previousEnv = Environment.GetEnvironmentVariable("DOTNET_ENVIRONMENT");
|
||||
try
|
||||
{
|
||||
@@ -222,7 +225,7 @@ public class HealthCheckTests : IDisposable
|
||||
Assert.Contains(ZbHealthTags.Active, active.Tags);
|
||||
|
||||
var check = active.Factory(factory.Services);
|
||||
Assert.IsType<OldestNodeActiveHealthCheck>(check);
|
||||
Assert.IsType<ActiveNodeHealthCheck>(check);
|
||||
}
|
||||
finally
|
||||
{
|
||||
|
||||
@@ -0,0 +1,239 @@
|
||||
using Microsoft.AspNetCore.Builder;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Diagnostics.HealthChecks;
|
||||
using Microsoft.Extensions.Options;
|
||||
using ZB.MOM.WW.Health;
|
||||
using ZB.MOM.WW.Health.Akka;
|
||||
using ZB.MOM.WW.LocalDb;
|
||||
using ZB.MOM.WW.LocalDb.Registration;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Messages.Health;
|
||||
using ZB.MOM.WW.ScadaBridge.HealthMonitoring;
|
||||
using ZB.MOM.WW.ScadaBridge.Host.Health;
|
||||
|
||||
namespace ZB.MOM.WW.ScadaBridge.Host.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Site-node health endpoints. Site nodes previously served no health surface at all — only gRPC on
|
||||
/// the HTTP/2 listener and <c>/metrics</c> on the HTTP/1.1 one — so the family overview dashboard
|
||||
/// had no uniform way to probe them. These tests pin the three checks the site now registers and
|
||||
/// the tier each one lands in.
|
||||
/// <para>
|
||||
/// The registration assertions build the REAL container from
|
||||
/// <see cref="SiteServiceRegistration.Configure"/> rather than a hand-assembled
|
||||
/// <c>ServiceCollection</c>, for the reason spelled out in <see cref="SiteLocalDbWiringTests"/>:
|
||||
/// this family's wiring defects have consistently been ones that only a container-built graph
|
||||
/// catches. Each registration is additionally ACTIVATED through its factory, so a check that is
|
||||
/// registered but cannot resolve its dependencies fails here instead of at the first live probe.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
public class SiteHealthCheckTests : IDisposable
|
||||
{
|
||||
private readonly WebApplication _host;
|
||||
private readonly string _tempDbPath;
|
||||
private readonly string _tempSiteDbPath;
|
||||
private readonly string _tempTrackingDbPath;
|
||||
|
||||
public SiteHealthCheckTests()
|
||||
{
|
||||
var stamp = Guid.NewGuid();
|
||||
_tempDbPath = Path.Combine(Path.GetTempPath(), $"scadabridge_health_localdb_{stamp}.db");
|
||||
_tempSiteDbPath = Path.Combine(Path.GetTempPath(), $"scadabridge_health_site_{stamp}.db");
|
||||
_tempTrackingDbPath = Path.Combine(Path.GetTempPath(), $"scadabridge_health_track_{stamp}.db");
|
||||
|
||||
var builder = WebApplication.CreateBuilder();
|
||||
builder.Configuration.Sources.Clear();
|
||||
builder.Configuration.AddInMemoryCollection(new Dictionary<string, string?>
|
||||
{
|
||||
["ScadaBridge:Node:Role"] = "Site",
|
||||
["ScadaBridge:Node:NodeName"] = "node-a",
|
||||
["ScadaBridge:Node:NodeHostname"] = "test-site",
|
||||
["ScadaBridge:Node:SiteId"] = "TestSite",
|
||||
["ScadaBridge:Node:RemotingPort"] = "0",
|
||||
["ScadaBridge:Node:GrpcPort"] = "0",
|
||||
["ScadaBridge:Database:SiteDbPath"] = _tempSiteDbPath,
|
||||
["ScadaBridge:OperationTracking:ConnectionString"] = $"Data Source={_tempTrackingDbPath}",
|
||||
["ScadaBridge:Cluster:SeedNodes:0"] = "akka.tcp://scadabridge@localhost:2551",
|
||||
["ScadaBridge:Cluster:SeedNodes:1"] = "akka.tcp://scadabridge@localhost:2552",
|
||||
["LocalDb:Path"] = _tempDbPath,
|
||||
});
|
||||
|
||||
builder.Services.AddGrpc();
|
||||
builder.Services.AddSingleton<ZB.MOM.WW.ScadaBridge.Communication.Grpc.SiteStreamGrpcServer>();
|
||||
|
||||
SiteServiceRegistration.Configure(builder.Services, builder.Configuration);
|
||||
|
||||
AkkaHostedServiceRemover.RemoveAkkaHostedServiceOnly(builder.Services);
|
||||
|
||||
_host = builder.Build();
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
(_host as IDisposable)?.Dispose();
|
||||
foreach (var path in new[] { _tempDbPath, _tempSiteDbPath, _tempTrackingDbPath })
|
||||
{
|
||||
try { File.Delete(path); } catch { /* best effort */ }
|
||||
}
|
||||
|
||||
GC.SuppressFinalize(this);
|
||||
}
|
||||
|
||||
private IReadOnlyList<HealthCheckRegistration> Registrations =>
|
||||
_host.Services.GetRequiredService<IOptions<HealthCheckServiceOptions>>().Value.Registrations.ToList();
|
||||
|
||||
[Fact]
|
||||
public void Site_RegistersExactlyTheThreeSiteChecks()
|
||||
{
|
||||
// Exact-set, not "contains": an extra check silently changes what /health/ready means for
|
||||
// every consumer, and a site node inheriting a central-only probe is precisely the mistake
|
||||
// this asserts against.
|
||||
Assert.Equal(
|
||||
new[] { "active-node", "akka-cluster", "localdb" },
|
||||
Registrations.Select(r => r.Name).OrderBy(n => n, StringComparer.Ordinal).ToArray());
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("akka-cluster", ZbHealthTags.Ready)]
|
||||
[InlineData("localdb", ZbHealthTags.Ready)]
|
||||
[InlineData("active-node", ZbHealthTags.Active)]
|
||||
public void Site_ChecksCarryTheirIntendedTier(string name, string expectedTag)
|
||||
{
|
||||
var registration = Assert.Single(Registrations, r => r.Name == name);
|
||||
|
||||
// Exactly one tier tag each: MapZbHealth selects by tag, so a check tagged both Ready and
|
||||
// Active would put standby-ness into readiness and drop the standby out of readiness too.
|
||||
Assert.Equal(new[] { expectedTag }, registration.Tags.ToArray());
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("akka-cluster", typeof(AkkaClusterHealthCheck))]
|
||||
[InlineData("localdb", typeof(SiteLocalDbHealthCheck))]
|
||||
[InlineData("active-node", typeof(SitePairActiveNodeHealthCheck))]
|
||||
public void Site_ChecksActivateToTheIntendedType(string name, Type expected)
|
||||
{
|
||||
var registration = Assert.Single(Registrations, r => r.Name == name);
|
||||
|
||||
// Running the factory is the point: a type-activated check whose constructor dependencies
|
||||
// are not registered on site would throw here rather than on the first probe in production.
|
||||
var check = registration.Factory(_host.Services);
|
||||
|
||||
Assert.IsType(expected, check);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Site_ActiveNodeCheck_IsRoleScoped_NotTheUnscopedCentralCheck()
|
||||
{
|
||||
// Central registers the shared ActiveNodeHealthCheck UNSCOPED — oldest Up member of the whole
|
||||
// cluster. On a mesh carrying more than one site's members that computes "oldest" across the
|
||||
// wrong member set, so a site node could report Active purely for being the oldest member
|
||||
// overall. A site must answer for its own site-{SiteId} role, which SitePairActiveNodeHealthCheck
|
||||
// does by delegating to the site's IClusterNodeProvider (itself now backed by the shared
|
||||
// ClusterActiveNode, so the rule is shared even though the scoping is local).
|
||||
var check = Assert.Single(Registrations, r => r.Name == "active-node").Factory(_host.Services);
|
||||
|
||||
Assert.IsType<SitePairActiveNodeHealthCheck>(check);
|
||||
Assert.IsNotType<ActiveNodeHealthCheck>(check);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("database")]
|
||||
[InlineData("required-singletons")]
|
||||
public void Site_DoesNotRegisterCentralOnlyChecks(string centralOnlyName)
|
||||
{
|
||||
// Positive control first: if the harness ever stopped registering health checks at all,
|
||||
// every "is absent" assertion below would pass vacuously and this test would be worthless.
|
||||
Assert.Contains(Registrations, r => r.Name == "akka-cluster");
|
||||
|
||||
Assert.DoesNotContain(Registrations, r => r.Name == centralOnlyName);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Site_LocalDbCheck_ReportsHealthyAgainstTheRealSiteDatabase()
|
||||
{
|
||||
// Behaviour, not wiring: the probe runs against the consolidated site database this
|
||||
// composition actually built, so a check that resolves but cannot query fails here.
|
||||
var check = (SiteLocalDbHealthCheck)Assert.Single(Registrations, r => r.Name == "localdb")
|
||||
.Factory(_host.Services);
|
||||
|
||||
var result = await check.CheckHealthAsync(NewContext("localdb", check));
|
||||
|
||||
Assert.Equal(HealthStatus.Healthy, result.Status);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Site_LocalDbCheck_EnrichesWithReplicationStateWithoutFailingOnDefaultOff()
|
||||
{
|
||||
// Replication ships default-OFF, so the unreplicated steady state must stay Healthy while
|
||||
// still surfacing the state as data — enrichment must never become a verdict.
|
||||
var check = (SiteLocalDbHealthCheck)Assert.Single(Registrations, r => r.Name == "localdb")
|
||||
.Factory(_host.Services);
|
||||
|
||||
var result = await check.CheckHealthAsync(NewContext("localdb", check));
|
||||
|
||||
Assert.Equal(HealthStatus.Healthy, result.Status);
|
||||
Assert.False(Assert.IsType<bool>(result.Data["replicationConnected"]));
|
||||
Assert.Equal(0L, Assert.IsType<long>(result.Data["replicationOplogBacklog"]));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Site_LocalDbCheck_ReportsUnhealthyWhenTheStoreIsUnreachable()
|
||||
{
|
||||
var check = new SiteLocalDbHealthCheck(new ThrowingLocalDb());
|
||||
|
||||
var result = await check.CheckHealthAsync(NewContext("localdb", check));
|
||||
|
||||
Assert.Equal(HealthStatus.Unhealthy, result.Status);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(true, HealthStatus.Healthy)]
|
||||
[InlineData(false, HealthStatus.Unhealthy)]
|
||||
public async Task Site_ActiveNodeCheck_SplitsPrimaryFromStandby(bool selfIsPrimary, HealthStatus expected)
|
||||
{
|
||||
// 200 on the primary / 503 on the standby is the same contract central publishes, so a
|
||||
// consumer reads one active-node meaning fleet-wide.
|
||||
var check = new SitePairActiveNodeHealthCheck(new StubNodeProvider(selfIsPrimary));
|
||||
|
||||
var result = await check.CheckHealthAsync(NewContext("active-node", check));
|
||||
|
||||
Assert.Equal(expected, result.Status);
|
||||
}
|
||||
|
||||
private static HealthCheckContext NewContext(string name, IHealthCheck check) => new()
|
||||
{
|
||||
Registration = new HealthCheckRegistration(name, check, HealthStatus.Unhealthy, tags: null),
|
||||
};
|
||||
|
||||
private sealed class StubNodeProvider(bool selfIsPrimary) : IClusterNodeProvider
|
||||
{
|
||||
public bool SelfIsPrimary { get; } = selfIsPrimary;
|
||||
|
||||
public IReadOnlyList<NodeStatus> GetClusterNodes() => [];
|
||||
}
|
||||
|
||||
private sealed class ThrowingLocalDb : ILocalDb
|
||||
{
|
||||
public Task<int> ExecuteAsync(string sql, object? parameters = null, CancellationToken ct = default) =>
|
||||
throw new InvalidOperationException("store unreachable");
|
||||
|
||||
public Task<IReadOnlyList<T>> QueryAsync<T>(
|
||||
string sql,
|
||||
Func<Microsoft.Data.Sqlite.SqliteDataReader, T> map,
|
||||
object? parameters = null,
|
||||
CancellationToken ct = default) =>
|
||||
throw new InvalidOperationException("store unreachable");
|
||||
|
||||
public Task<ILocalDbTransaction> BeginTransactionAsync(CancellationToken ct = default) =>
|
||||
throw new InvalidOperationException("store unreachable");
|
||||
|
||||
public Microsoft.Data.Sqlite.SqliteConnection CreateConnection() =>
|
||||
throw new InvalidOperationException("store unreachable");
|
||||
|
||||
public ReplicatedTable RegisterReplicated(string tableName) =>
|
||||
throw new InvalidOperationException("store unreachable");
|
||||
|
||||
public IReadOnlyDictionary<string, ReplicatedTable> ReplicatedTables =>
|
||||
throw new InvalidOperationException("store unreachable");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,156 @@
|
||||
using System.Net;
|
||||
using System.Text.Json;
|
||||
using Microsoft.AspNetCore.Hosting;
|
||||
using Microsoft.AspNetCore.Mvc.Testing;
|
||||
using Microsoft.AspNetCore.TestHost;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
|
||||
namespace ZB.MOM.WW.ScadaBridge.Host.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Proves the site pipeline actually MAPS the health endpoints — the half
|
||||
/// <see cref="SiteHealthCheckTests"/> cannot cover, since that fixture builds the site container
|
||||
/// from <see cref="SiteServiceRegistration"/> and never runs <c>Program</c>'s endpoint wiring.
|
||||
/// Without this, deleting <c>app.MapZbHealth()</c> from the site branch would leave every
|
||||
/// registration test green while site nodes served 404 to the overview dashboard.
|
||||
/// <para>
|
||||
/// Boots the real <c>Program</c> in the Site role. <c>Program</c> builds its own
|
||||
/// <c>ConfigurationBuilder</c> before the web host exists, so its role and settings come from
|
||||
/// PROCESS-WIDE environment variables (<c>SCADABRIDGE_CONFIG</c> selects appsettings.Site.json)
|
||||
/// rather than from <c>WithWebHostBuilder</c> — which is why this fixture joins
|
||||
/// <see cref="HostBootCollection"/> and restores every var it sets.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
[Collection(HostBootCollection.Name)]
|
||||
public class SiteHealthEndpointTests : IDisposable
|
||||
{
|
||||
private readonly List<IDisposable> _disposables = new();
|
||||
private readonly Dictionary<string, string?> _previousEnv = new(StringComparer.Ordinal);
|
||||
private readonly string _tempDbPath;
|
||||
|
||||
public SiteHealthEndpointTests()
|
||||
{
|
||||
_tempDbPath = Path.Combine(Path.GetTempPath(), $"scadabridge_health_ep_{Guid.NewGuid()}.db");
|
||||
|
||||
// Whole-key env overrides, the sanctioned path: supplying GrpcPsk concretely makes the
|
||||
// pre-host secrets expander skip appsettings.Site.json's ${secret:SB-GRPC-PSK-site-1}
|
||||
// token, so this test needs no seeded secrets store.
|
||||
//
|
||||
// The remoting port is moved off the site default (8082) so this fixture cannot collide
|
||||
// with a locally running node, but it must be a REAL port and seed-nodes[0] must be this
|
||||
// node itself — StartupValidator enforces both before the host is built, and it runs
|
||||
// whether or not the Akka hosted service is later removed. Nothing binds it: the hosted
|
||||
// service is removed below.
|
||||
SetEnv("SCADABRIDGE_CONFIG", "Site");
|
||||
SetEnv("ScadaBridge__Node__Role", "Site");
|
||||
SetEnv("ScadaBridge__Node__SiteId", "TestSite");
|
||||
SetEnv("ScadaBridge__Node__NodeHostname", "localhost");
|
||||
SetEnv("ScadaBridge__Node__RemotingPort", "18082");
|
||||
SetEnv("ScadaBridge__Cluster__SeedNodes__0", "akka.tcp://scadabridge@localhost:18082");
|
||||
SetEnv("ScadaBridge__Cluster__SeedNodes__1", "akka.tcp://scadabridge@localhost:18085");
|
||||
SetEnv("ScadaBridge__Communication__GrpcPsk", "test-psk-0123456789");
|
||||
SetEnv("LocalDb__Path", _tempDbPath);
|
||||
}
|
||||
|
||||
private void SetEnv(string key, string? value)
|
||||
{
|
||||
_previousEnv[key] = Environment.GetEnvironmentVariable(key);
|
||||
Environment.SetEnvironmentVariable(key, value);
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
foreach (var d in _disposables)
|
||||
{
|
||||
try { d.Dispose(); } catch { /* best effort */ }
|
||||
}
|
||||
|
||||
foreach (var (key, value) in _previousEnv)
|
||||
{
|
||||
Environment.SetEnvironmentVariable(key, value);
|
||||
}
|
||||
|
||||
try { File.Delete(_tempDbPath); } catch { /* best effort */ }
|
||||
|
||||
GC.SuppressFinalize(this);
|
||||
}
|
||||
|
||||
private HttpClient CreateSiteClient()
|
||||
{
|
||||
var factory = new WebApplicationFactory<Program>()
|
||||
.WithWebHostBuilder(builder =>
|
||||
{
|
||||
// WebApplicationFactory defaults the environment to Development, which turns on
|
||||
// ValidateOnBuild/ValidateScopes. The site graph carries scoped registrations whose
|
||||
// dependencies are central-only (ReconcileService → IDeploymentManagerRepository,
|
||||
// AuditLogKpiSampleSource → IAuditLogRepository) and are never resolved on a site
|
||||
// node, so eager validation fails on a composition that runs fine in production.
|
||||
// That is a pre-existing property of the site container, not of the health wiring
|
||||
// under test — run this fixture the way a site node actually runs.
|
||||
builder.UseEnvironment("Production");
|
||||
|
||||
// ConfigureTestServices runs after the app's own registrations, so this drops the
|
||||
// hosted service that would form a real cluster while leaving the AkkaHostedService
|
||||
// singleton resolvable (the site pipeline resolves it for shutdown ordering).
|
||||
builder.ConfigureTestServices(AkkaHostedServiceRemover.RemoveAkkaHostedServiceOnly);
|
||||
});
|
||||
_disposables.Add(factory);
|
||||
|
||||
var client = factory.CreateClient();
|
||||
_disposables.Add(client);
|
||||
return client;
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Site_MapsHealthReady_WithTheCanonicalJsonBody()
|
||||
{
|
||||
var response = await CreateSiteClient().GetAsync("/health/ready");
|
||||
|
||||
// Never 404. 200 vs 503 depends on cluster state this fixture does not form, so both are
|
||||
// accepted — what is asserted is that the tier is mapped and answers in canonical shape.
|
||||
Assert.NotEqual(HttpStatusCode.NotFound, response.StatusCode);
|
||||
Assert.True(
|
||||
response.StatusCode is HttpStatusCode.OK or HttpStatusCode.ServiceUnavailable,
|
||||
$"Expected 200 or 503, got {(int)response.StatusCode}");
|
||||
|
||||
using var doc = JsonDocument.Parse(await response.Content.ReadAsStringAsync());
|
||||
var entries = doc.RootElement.GetProperty("entries");
|
||||
|
||||
Assert.True(entries.TryGetProperty("akka-cluster", out _), "ready tier must carry akka-cluster");
|
||||
Assert.True(entries.TryGetProperty("localdb", out _), "ready tier must carry localdb");
|
||||
// Readiness must NOT depend on being the primary, or a healthy standby would read unready.
|
||||
Assert.False(entries.TryGetProperty("active-node", out _), "active-node belongs to the Active tier");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Site_MapsHealthActive_CarryingOnlyTheActiveNodeCheck()
|
||||
{
|
||||
var response = await CreateSiteClient().GetAsync("/health/active");
|
||||
|
||||
Assert.NotEqual(HttpStatusCode.NotFound, response.StatusCode);
|
||||
|
||||
using var doc = JsonDocument.Parse(await response.Content.ReadAsStringAsync());
|
||||
var entries = doc.RootElement.GetProperty("entries");
|
||||
|
||||
Assert.Equal(
|
||||
new[] { "active-node" },
|
||||
entries.EnumerateObject().Select(p => p.Name).ToArray());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Site_HealthEndpointsAreAnonymous()
|
||||
{
|
||||
// The dashboard authenticates nowhere. The site pipeline runs no authentication middleware
|
||||
// today, so this is a regression pin: adding one later must not silently close these.
|
||||
var client = CreateSiteClient();
|
||||
|
||||
foreach (var path in new[] { "/health/ready", "/health/active", "/healthz" })
|
||||
{
|
||||
var response = await client.GetAsync(path);
|
||||
|
||||
Assert.NotEqual(HttpStatusCode.Unauthorized, response.StatusCode);
|
||||
Assert.NotEqual(HttpStatusCode.Forbidden, response.StatusCode);
|
||||
Assert.NotEqual(HttpStatusCode.NotFound, response.StatusCode);
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user