feat(replay): truthful snapshot index runtime

Sprint SPRINT_20260416_004_Replay_truthful_snapshot_index_cutover.

Replay WebService program wiring; runtime startup contract tests,
point-in-time query API integration tests, test environment variable
scope helper.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
master
2026-04-19 14:39:09 +03:00
parent 32551baf0e
commit 9d569fdeb8
6 changed files with 223 additions and 43 deletions

View File

@@ -59,7 +59,7 @@ builder.Services.AddSingleton<IAuditBundleReader, AuditBundleReader>();
builder.Services.AddSingleton<IVerdictReplayPredicate, VerdictReplayPredicate>();
var replayStorageDriver = ResolveStorageDriver(builder.Configuration, "Replay");
RegisterSnapshotIndexStore(builder.Services, builder.Configuration, builder.Environment.IsDevelopment(), replayStorageDriver);
RegisterSnapshotIndexStore(builder.Services, builder.Configuration, builder.Environment, replayStorageDriver);
RegisterSnapshotBlobStore(builder.Services, builder.Configuration, "Replay");
builder.Services.AddSingleton(new FeedSnapshotServiceOptions());
builder.Services.AddSingleton<FeedSnapshotService>();
@@ -436,22 +436,17 @@ await app.LoadTranslationsAsync();
app.TryRefreshStellaRouterEndpoints(routerEnabled);
await app.RunAsync().ConfigureAwait(false);
static void RegisterSnapshotIndexStore(IServiceCollection services, IConfiguration configuration, bool isDevelopment, string storageDriver)
static void RegisterSnapshotIndexStore(IServiceCollection services, IConfiguration configuration, IHostEnvironment environment, string storageDriver)
{
if (string.Equals(storageDriver, "postgres", StringComparison.OrdinalIgnoreCase))
{
var connectionString = ResolvePostgresConnectionString(configuration, "Replay");
if (string.IsNullOrWhiteSpace(connectionString))
{
if (!isDevelopment)
{
throw new InvalidOperationException(
"Replay requires PostgreSQL connection settings in non-development mode. " +
"Set ConnectionStrings:Default or Replay:Storage:Postgres:ConnectionString.");
}
services.AddSingleton<IFeedSnapshotIndexStore, InMemoryFeedSnapshotIndexStore>();
return;
throw new InvalidOperationException(
"Replay requires PostgreSQL connection settings when Storage:Driver=postgres. " +
"Set ConnectionStrings:Default or Replay:Storage:Postgres:ConnectionString. " +
"Live runtime no longer falls back to in-memory snapshot indexing; tests may opt into Replay:Storage:Driver=inmemory only in the Testing environment.");
}
services.AddSingleton<IFeedSnapshotIndexStore>(_ => new PostgresFeedSnapshotIndexStore(connectionString));
@@ -460,6 +455,13 @@ static void RegisterSnapshotIndexStore(IServiceCollection services, IConfigurati
if (string.Equals(storageDriver, "inmemory", StringComparison.OrdinalIgnoreCase))
{
if (!environment.IsEnvironment("Testing"))
{
throw new InvalidOperationException(
"Replay storage driver 'inmemory' is supported only in the Testing environment. " +
"Use PostgreSQL-backed snapshot indexing for Development and live runtime.");
}
services.AddSingleton<IFeedSnapshotIndexStore, InMemoryFeedSnapshotIndexStore>();
return;
}
@@ -492,8 +494,8 @@ static void RegisterSnapshotBlobStore(IServiceCollection services, IConfiguratio
static string ResolveStorageDriver(IConfiguration configuration, string serviceName)
{
return FirstNonEmpty(
configuration["Storage:Driver"],
configuration[$"{serviceName}:Storage:Driver"])
configuration[$"{serviceName}:Storage:Driver"],
configuration["Storage:Driver"])
?? "postgres";
}

View File

@@ -9,3 +9,4 @@ Source of truth: `docs/implplan/SPRINT_20260130_002_Tools_csproj_remediation_sol
| REMED-06 | DONE | SOLID review notes captured for SPRINT_20260130_002. |
| SPRINT-312-006 | DONE | Added Postgres snapshot index + seed-fs snapshot blob stores and wired storage-driver registration in webservice startup. |
| SPRINT-20260305-003 | DONE | Replay storage contract closed: object-store narrowed to seed-fs only with deterministic rustfs/unknown-driver startup rejection and synced docs. |
| REPLAY-REAL-001 | DONE | 2026-04-16: Removed the implicit Development fallback to `InMemoryFeedSnapshotIndexStore`; `Replay:Storage:Driver=inmemory` is now `Testing`-only and covered by startup-contract tests. |

View File

@@ -144,37 +144,13 @@ public sealed class PointInTimeQueryApiIntegrationTests
private static WebApplicationFactory<Program> CreateFactory()
{
return new WebApplicationFactory<Program>()
.WithWebHostBuilder(builder =>
{
builder.UseEnvironment("Development");
builder.ConfigureAppConfiguration((_, configurationBuilder) =>
{
configurationBuilder.AddInMemoryCollection(new Dictionary<string, string?>
{
["Replay:Authority:Issuer"] = "https://authority.stella-ops.local",
["Replay:Authority:MetadataAddress"] = "https://authority.stella-ops.local/.well-known/openid-configuration",
["Replay:Authority:RequireHttpsMetadata"] = "false",
});
});
builder.ConfigureTestServices(services =>
{
services.AddAuthentication(TestReplayAuthHandler.SchemeName)
.AddScheme<AuthenticationSchemeOptions, TestReplayAuthHandler>(
TestReplayAuthHandler.SchemeName,
_ => { });
var environmentScope = new TestEnvironmentVariableScope(new Dictionary<string, string?>
{
["REPLAY_Replay__Storage__Driver"] = "inmemory",
["REPLAY_Storage__Driver"] = "inmemory"
});
services.PostConfigureAll<AuthenticationOptions>(options =>
{
options.DefaultAuthenticateScheme = TestReplayAuthHandler.SchemeName;
options.DefaultChallengeScheme = TestReplayAuthHandler.SchemeName;
options.DefaultScheme = TestReplayAuthHandler.SchemeName;
});
services.RemoveAll<IAuthorizationHandler>();
services.AddSingleton<IAuthorizationHandler, AllowAllAuthorizationHandler>();
});
});
return new ScopedReplayWebApplicationFactory(environmentScope);
}
private static void ConfigureClient(HttpClient client)
@@ -220,4 +196,70 @@ public sealed class PointInTimeQueryApiIntegrationTests
return Task.CompletedTask;
}
}
private sealed class ScopedReplayWebApplicationFactory : WebApplicationFactory<Program>
{
private readonly IDisposable _environmentScope;
private bool _disposed;
public ScopedReplayWebApplicationFactory(IDisposable environmentScope)
{
_environmentScope = environmentScope;
}
protected override void Dispose(bool disposing)
{
base.Dispose(disposing);
if (disposing && !_disposed)
{
_environmentScope.Dispose();
_disposed = true;
}
}
public override async ValueTask DisposeAsync()
{
await base.DisposeAsync();
if (_disposed)
{
return;
}
_environmentScope.Dispose();
_disposed = true;
}
protected override void ConfigureWebHost(IWebHostBuilder builder)
{
builder.UseEnvironment("Testing");
builder.ConfigureAppConfiguration((_, configurationBuilder) =>
{
configurationBuilder.AddInMemoryCollection(new Dictionary<string, string?>
{
["Replay:Authority:Issuer"] = "https://authority.stella-ops.local",
["Replay:Authority:MetadataAddress"] = "https://authority.stella-ops.local/.well-known/openid-configuration",
["Replay:Authority:RequireHttpsMetadata"] = "false"
});
});
builder.ConfigureTestServices(services =>
{
services.AddAuthentication(TestReplayAuthHandler.SchemeName)
.AddScheme<AuthenticationSchemeOptions, TestReplayAuthHandler>(
TestReplayAuthHandler.SchemeName,
_ => { });
services.PostConfigureAll<AuthenticationOptions>(options =>
{
options.DefaultAuthenticateScheme = TestReplayAuthHandler.SchemeName;
options.DefaultChallengeScheme = TestReplayAuthHandler.SchemeName;
options.DefaultScheme = TestReplayAuthHandler.SchemeName;
});
services.RemoveAll<IAuthorizationHandler>();
services.AddSingleton<IAuthorizationHandler, AllowAllAuthorizationHandler>();
});
}
}
}

View File

@@ -0,0 +1,97 @@
using System.Net;
using FluentAssertions;
using Microsoft.AspNetCore.Hosting;
using Microsoft.AspNetCore.Mvc.Testing;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using StellaOps.Replay.Core.FeedSnapshots;
using StellaOps.Replay.WebService;
using Xunit;
namespace StellaOps.Replay.Core.Tests.FeedSnapshots;
[Trait("Category", "Integration")]
[Trait("Intent", "Operational")]
public sealed class ReplayRuntimeStartupContractTests
{
[Fact]
public void DevelopmentHost_RejectsMissingPostgresSnapshotIndexConfiguration()
{
using var factory = CreateFactory("Development");
var act = () => factory.CreateClient();
act.Should()
.Throw<InvalidOperationException>()
.WithMessage("*Replay requires PostgreSQL connection settings when Storage:Driver=postgres*");
}
[Fact]
public void DevelopmentHost_RejectsExplicitInMemorySnapshotIndexDriver()
{
using var environmentScope = CreateReplayEnvironmentScope("inmemory");
using var factory = CreateFactory(
"Development");
var act = () => factory.CreateClient();
act.Should()
.Throw<InvalidOperationException>()
.WithMessage("*Replay storage driver 'inmemory' is supported only in the Testing environment*");
}
[Fact]
public async Task TestingHost_AllowsExplicitInMemorySnapshotIndexDriver()
{
using var environmentScope = CreateReplayEnvironmentScope("inmemory");
using var factory = CreateFactory(
"Testing");
using var client = factory.CreateClient();
var health = await client.GetAsync("/healthz");
health.StatusCode.Should().Be(HttpStatusCode.OK);
var store = factory.Services.GetRequiredService<IFeedSnapshotIndexStore>();
store.GetType().Name.Should().Be("InMemoryFeedSnapshotIndexStore");
}
private static WebApplicationFactory<Program> CreateFactory(
string environment,
IReadOnlyDictionary<string, string?>? overrides = null)
{
return new WebApplicationFactory<Program>()
.WithWebHostBuilder(builder =>
{
builder.UseEnvironment(environment);
builder.ConfigureAppConfiguration((_, configurationBuilder) =>
{
var values = new Dictionary<string, string?>
{
["Replay:Authority:Issuer"] = "https://authority.stella-ops.local",
["Replay:Authority:MetadataAddress"] = "https://authority.stella-ops.local/.well-known/openid-configuration",
["Replay:Authority:RequireHttpsMetadata"] = "false",
};
if (overrides is not null)
{
foreach (var pair in overrides)
{
values[pair.Key] = pair.Value;
}
}
configurationBuilder.AddInMemoryCollection(values);
});
});
}
private static TestEnvironmentVariableScope CreateReplayEnvironmentScope(string storageDriver)
{
return new TestEnvironmentVariableScope(new Dictionary<string, string?>
{
["REPLAY_Replay__Storage__Driver"] = storageDriver,
["REPLAY_Storage__Driver"] = storageDriver
});
}
}

View File

@@ -8,3 +8,4 @@ Source of truth: `docs/implplan/SPRINT_20260130_002_Tools_csproj_remediation_sol
| REMED-06 | DONE | SOLID review notes captured for SPRINT_20260130_002. |
| SPRINT-312-006 | DONE | Added `ReplayFeedSnapshotStoresTests` and validated Postgres index + seed-fs blob stores via class-targeted xUnit execution (3/3 pass). |
| SPRINT-20260305-003 | DONE | Added authenticated replay API integration test harness and revalidated Replay core suite (`dotnet test ...` passed 99/99). |
| REPLAY-REAL-001 | DONE | 2026-04-16: Added Replay startup-contract tests proving missing Postgres config and explicit `inmemory` driver both fail outside `Testing`, while `Testing` may opt in explicitly. |

View File

@@ -0,0 +1,37 @@
using System.Collections;
namespace StellaOps.Replay.Core.Tests;
internal sealed class TestEnvironmentVariableScope : IDisposable
{
private readonly Dictionary<string, string?> _originalValues = new(StringComparer.OrdinalIgnoreCase);
private bool _disposed;
public TestEnvironmentVariableScope(IReadOnlyDictionary<string, string?> variables)
{
foreach (var key in variables.Keys)
{
_originalValues[key] = Environment.GetEnvironmentVariable(key);
}
foreach (var pair in variables)
{
Environment.SetEnvironmentVariable(pair.Key, pair.Value);
}
}
public void Dispose()
{
if (_disposed)
{
return;
}
foreach (var pair in _originalValues)
{
Environment.SetEnvironmentVariable(pair.Key, pair.Value);
}
_disposed = true;
}
}