c4caebe9b4
Both NotifyDispatcher_AuditWriter_Throws_DeliveryStillSucceeds and NotificationDispatch_BrokenAuditWriter_StillTransitionsToDelivered read the throwing writer's attempt counter with a bare Assert immediately after an AwaitAssert on the Notifications row reaching Delivered. That assumes the audit writes happen no later than the operational status write, which the dispatcher deliberately does NOT guarantee: DeliverOneAsync persists the delivery state first (NotificationOutboxActor.cs:657) and only then emits the Attempted (:663) and terminal (:676) audit rows — audit is best-effort and must never gate the user-facing action. Observing Delivered therefore establishes no happens-before edge with the writer, and under a loaded full-solution parallel run the continuation after the DB write can be scheduled after the poll that saw Delivered, so the counter reads 0 and the test fails with "saw 0". Reproduced deterministically by delaying only the post-update audit emission, which yields both observed failure messages verbatim; with the fix in place the same injected delay passes, and suppressing the emissions entirely still fails both tests with the identical messages — the claims (delivery despite audit failure, and attempts >= N) are unchanged in force, only the ordering assumption is gone. Test-only change; the update-then-audit ordering predates the remediation (#23 M4) and is correct as written.
494 lines
21 KiB
C#
494 lines
21 KiB
C#
using Akka.Actor;
|
|
using Akka.TestKit.Xunit2;
|
|
using Microsoft.AspNetCore.Builder;
|
|
using Microsoft.AspNetCore.Hosting;
|
|
using Microsoft.AspNetCore.Http;
|
|
using Microsoft.AspNetCore.TestHost;
|
|
using Microsoft.Data.Sqlite;
|
|
using Microsoft.EntityFrameworkCore;
|
|
using Microsoft.Extensions.DependencyInjection;
|
|
using Microsoft.Extensions.Hosting;
|
|
using Microsoft.Extensions.Logging.Abstractions;
|
|
using Microsoft.Extensions.Options;
|
|
using NSubstitute;
|
|
using ZB.MOM.WW.Audit;
|
|
using IAuditWriter = ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Services.IAuditWriter;
|
|
using ZB.MOM.WW.ScadaBridge.Commons.Types.Audit;
|
|
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Notifications;
|
|
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Repositories;
|
|
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Services;
|
|
using ZB.MOM.WW.ScadaBridge.Commons.Messages.Integration;
|
|
using ZB.MOM.WW.ScadaBridge.Commons.Types;
|
|
using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums;
|
|
using ZB.MOM.WW.ScadaBridge.ConfigurationDatabase;
|
|
using ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Repositories;
|
|
using ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Tests.Migrations;
|
|
using ZB.MOM.WW.ScadaBridge.InboundAPI.Middleware;
|
|
using ZB.MOM.WW.ScadaBridge.NotificationOutbox;
|
|
using ZB.MOM.WW.ScadaBridge.NotificationOutbox.Delivery;
|
|
using ZB.MOM.WW.ScadaBridge.NotificationOutbox.Messages;
|
|
using ZB.MOM.WW.ScadaBridge.SiteRuntime.Scripts;
|
|
using System.Net;
|
|
using System.Security.Claims;
|
|
using System.Text;
|
|
using System.Text.Encodings.Web;
|
|
|
|
namespace ZB.MOM.WW.ScadaBridge.AuditLog.Tests.Integration;
|
|
|
|
/// <summary>
|
|
/// Audit Log #23 — M4 Bundle E (Task E4) cross-boundary safety suite verifying
|
|
/// the alog.md §13 contract: an always-throwing audit writer NEVER aborts the
|
|
/// user-facing action. Exercises every boundary that emits audit rows in M2,
|
|
/// M3, and M4:
|
|
///
|
|
/// <list type="bullet">
|
|
/// <item><description>External system sync call (M2 Bundle F).</description></item>
|
|
/// <item><description>External system cached call (M3 Bundle E).</description></item>
|
|
/// <item><description>Database sync write (M4 Bundle A).</description></item>
|
|
/// <item><description>Inbound API request (M4 Bundle D).</description></item>
|
|
/// <item><description>Notification dispatcher (M4 Bundle B).</description></item>
|
|
/// </list>
|
|
///
|
|
/// <para>
|
|
/// The site-local boundaries (ESG sync/cached, DB sync) take the always-throw
|
|
/// <see cref="ThrowingAuditWriter"/> in place of the production
|
|
/// <see cref="IAuditWriter"/>; the central boundaries (Inbound API,
|
|
/// Notification dispatcher) take the always-throw
|
|
/// <see cref="ThrowingCentralAuditWriter"/> in place of
|
|
/// <see cref="ICentralAuditWriter"/>. In each case the wrapped action's
|
|
/// original return value (or original exception) must still flow back to the
|
|
/// caller untouched.
|
|
/// </para>
|
|
/// </remarks>
|
|
public class AuditWriteFailureSafetyTests : TestKit, IClassFixture<MsSqlMigrationFixture>
|
|
{
|
|
private readonly MsSqlMigrationFixture _fixture;
|
|
|
|
public AuditWriteFailureSafetyTests(MsSqlMigrationFixture fixture)
|
|
{
|
|
_fixture = fixture;
|
|
}
|
|
|
|
// ---------------------------------------------------------------------
|
|
// Always-throwing writer test doubles
|
|
// ---------------------------------------------------------------------
|
|
|
|
/// <summary>
|
|
/// Site-side <see cref="IAuditWriter"/> that ALWAYS throws on
|
|
/// <see cref="WriteAsync"/>. Used to verify that ESG / DB script-side
|
|
/// helpers swallow the throw and return their normal result to the script.
|
|
/// </summary>
|
|
private sealed class ThrowingAuditWriter : IAuditWriter
|
|
{
|
|
private int _attempts;
|
|
public int Attempts => Volatile.Read(ref _attempts);
|
|
|
|
public Task WriteAsync(AuditEvent evt, CancellationToken ct = default)
|
|
{
|
|
Interlocked.Increment(ref _attempts);
|
|
return Task.FromException(new InvalidOperationException(
|
|
"test-only ThrowingAuditWriter — audit pipeline unavailable"));
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Central-side <see cref="ICentralAuditWriter"/> that ALWAYS throws on
|
|
/// <see cref="WriteAsync"/>. Used to verify Inbound API + Notification
|
|
/// dispatcher absorb audit-write failures rather than propagating them
|
|
/// into the response / state transition.
|
|
/// </summary>
|
|
private sealed class ThrowingCentralAuditWriter : ICentralAuditWriter
|
|
{
|
|
private int _attempts;
|
|
public int Attempts => Volatile.Read(ref _attempts);
|
|
|
|
public Task WriteAsync(AuditEvent evt, CancellationToken ct = default)
|
|
{
|
|
Interlocked.Increment(ref _attempts);
|
|
throw new InvalidOperationException(
|
|
"test-only ThrowingCentralAuditWriter — audit subsystem unavailable");
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Site-side <see cref="ICachedCallTelemetryForwarder"/> that ALWAYS
|
|
/// throws on <see cref="ForwardAsync"/>. The cached-call helpers absorb
|
|
/// the throw and still return a valid <see cref="TrackedOperationId"/>.
|
|
/// </summary>
|
|
private sealed class ThrowingCachedForwarder : ICachedCallTelemetryForwarder
|
|
{
|
|
private int _attempts;
|
|
public int Attempts => Volatile.Read(ref _attempts);
|
|
|
|
public Task ForwardAsync(CachedCallTelemetry telemetry, CancellationToken ct = default)
|
|
{
|
|
Interlocked.Increment(ref _attempts);
|
|
return Task.FromException(new InvalidOperationException(
|
|
"test-only ThrowingCachedForwarder — telemetry pipeline unavailable"));
|
|
}
|
|
}
|
|
|
|
// ---------------------------------------------------------------------
|
|
// Test 1 — ESG sync call still returns the original ExternalCallResult.
|
|
// ---------------------------------------------------------------------
|
|
|
|
[Fact]
|
|
public async Task EsgSyncCall_BrokenAuditWriter_StillReturnsResult()
|
|
{
|
|
var client = Substitute.For<IExternalSystemClient>();
|
|
var expected = new ExternalCallResult(
|
|
Success: true,
|
|
ResponseJson: "{\"orderId\":42}",
|
|
ErrorMessage: null,
|
|
WasBuffered: false);
|
|
client.CallAsync(
|
|
"ERP", "GetOrder",
|
|
Arg.Any<IReadOnlyDictionary<string, object?>?>(),
|
|
Arg.Any<CancellationToken>())
|
|
.Returns(expected);
|
|
|
|
var writer = new ThrowingAuditWriter();
|
|
var helper = new ScriptRuntimeContext.ExternalSystemHelper(
|
|
client,
|
|
instanceName: "Plant.Pump42",
|
|
NullLogger.Instance,
|
|
Guid.NewGuid(),
|
|
auditWriter: writer,
|
|
siteId: "site-77",
|
|
sourceScript: "ScriptActor:Sync",
|
|
cachedForwarder: null);
|
|
|
|
var result = await helper.Call("ERP", "GetOrder");
|
|
|
|
Assert.Same(expected, result);
|
|
// Proof the audit writer was attempted — otherwise the test wouldn't
|
|
// actually exercise the safety contract.
|
|
Assert.True(writer.Attempts >= 1,
|
|
$"Expected audit writer to be invoked at least once; saw {writer.Attempts}.");
|
|
}
|
|
|
|
// ---------------------------------------------------------------------
|
|
// Test 2 — ESG cached call still returns a TrackedOperationId.
|
|
// ---------------------------------------------------------------------
|
|
|
|
[Fact]
|
|
public async Task EsgCachedCall_BrokenAuditWriter_StillReturnsTrackedOperationId()
|
|
{
|
|
var client = Substitute.For<IExternalSystemClient>();
|
|
// CachedCallAsync returns WasBuffered=true so the helper takes the
|
|
// S&F-deferred path — no immediate-terminal telemetry, which keeps the
|
|
// forwarder attempt count at exactly one (the CachedSubmit emission).
|
|
client.CachedCallAsync(
|
|
"ERP", "GetOrder",
|
|
Arg.Any<IReadOnlyDictionary<string, object?>?>(),
|
|
Arg.Any<string?>(),
|
|
Arg.Any<CancellationToken>(),
|
|
Arg.Any<TrackedOperationId?>())
|
|
.Returns(new ExternalCallResult(true, null, null, WasBuffered: true));
|
|
|
|
// BOTH the audit writer AND the cached forwarder throw — the
|
|
// CachedSubmit emission goes through the forwarder in production, so
|
|
// breaking only the writer wouldn't actually exercise the cached
|
|
// path's safety contract.
|
|
var writer = new ThrowingAuditWriter();
|
|
var forwarder = new ThrowingCachedForwarder();
|
|
var helper = new ScriptRuntimeContext.ExternalSystemHelper(
|
|
client,
|
|
instanceName: "Plant.Pump42",
|
|
NullLogger.Instance,
|
|
Guid.NewGuid(),
|
|
auditWriter: writer,
|
|
siteId: "site-77",
|
|
sourceScript: "ScriptActor:Cached",
|
|
cachedForwarder: forwarder);
|
|
|
|
var trackedId = await helper.CachedCall("ERP", "GetOrder");
|
|
|
|
// Non-default id materialised despite the forwarder failing.
|
|
Assert.NotEqual(default, trackedId);
|
|
Assert.True(forwarder.Attempts >= 1,
|
|
$"Expected cached forwarder to be invoked at least once; saw {forwarder.Attempts}.");
|
|
}
|
|
|
|
// ---------------------------------------------------------------------
|
|
// Test 3 — DB sync write still returns the rows-affected count.
|
|
// ---------------------------------------------------------------------
|
|
|
|
[Fact]
|
|
public async Task DbSyncWrite_BrokenAuditWriter_StillReturnsRowsAffected()
|
|
{
|
|
const string connectionName = "machineData";
|
|
const string instanceName = "Plant.Pump42";
|
|
|
|
using var keepAlive = new SqliteConnection(
|
|
"Data Source=k-safety-db;Mode=Memory;Cache=Shared");
|
|
keepAlive.Open();
|
|
|
|
// Schema + seed inside a unique in-memory DB.
|
|
var dbName = $"db-{Guid.NewGuid():N}";
|
|
var connStr = $"Data Source={dbName};Mode=Memory;Cache=Shared";
|
|
using var dbKeepAlive = new SqliteConnection(connStr);
|
|
dbKeepAlive.Open();
|
|
using (var seed = dbKeepAlive.CreateCommand())
|
|
{
|
|
seed.CommandText =
|
|
"CREATE TABLE t (id INTEGER PRIMARY KEY, name TEXT NOT NULL);";
|
|
seed.ExecuteNonQuery();
|
|
}
|
|
var inner = new SqliteConnection(connStr);
|
|
inner.Open();
|
|
|
|
var gateway = Substitute.For<IDatabaseGateway>();
|
|
gateway.GetConnectionAsync(connectionName, Arg.Any<CancellationToken>())
|
|
.Returns(inner);
|
|
|
|
var writer = new ThrowingAuditWriter();
|
|
var helper = new ScriptRuntimeContext.DatabaseHelper(
|
|
gateway,
|
|
instanceName,
|
|
NullLogger.Instance,
|
|
Guid.NewGuid(),
|
|
auditWriter: writer,
|
|
siteId: "site-77",
|
|
sourceScript: "ScriptActor:Db",
|
|
cachedForwarder: null);
|
|
|
|
await using (var conn = await helper.Connection(connectionName))
|
|
await using (var cmd = conn.CreateCommand())
|
|
{
|
|
cmd.CommandText = "INSERT INTO t (id, name) VALUES (1, 'safety')";
|
|
var rows = await cmd.ExecuteNonQueryAsync();
|
|
Assert.Equal(1, rows);
|
|
}
|
|
|
|
Assert.True(writer.Attempts >= 1,
|
|
$"Expected audit writer to be invoked at least once; saw {writer.Attempts}.");
|
|
}
|
|
|
|
// ---------------------------------------------------------------------
|
|
// Test 4 — Inbound API request still returns HTTP 200.
|
|
// ---------------------------------------------------------------------
|
|
|
|
[Fact]
|
|
public async Task InboundApi_BrokenAuditWriter_StillReturns200()
|
|
{
|
|
var writer = new ThrowingCentralAuditWriter();
|
|
|
|
using var host = await BuildInboundApiHostAsync(writer, endpointStatus: 200);
|
|
var client = host.GetTestClient();
|
|
|
|
var resp = await client.PostAsync(
|
|
"/api/echo",
|
|
new StringContent("{\"x\":1}", Encoding.UTF8, "application/json"));
|
|
|
|
Assert.Equal(HttpStatusCode.OK, resp.StatusCode);
|
|
Assert.True(writer.Attempts >= 1,
|
|
$"Expected central audit writer to be invoked at least once; saw {writer.Attempts}.");
|
|
}
|
|
|
|
// ---------------------------------------------------------------------
|
|
// Test 5 — Notification dispatcher still transitions to Delivered.
|
|
// ---------------------------------------------------------------------
|
|
|
|
[SkippableFact]
|
|
public async Task NotificationDispatch_BrokenAuditWriter_StillTransitionsToDelivered()
|
|
{
|
|
Skip.IfNot(_fixture.Available, _fixture.SkipReason);
|
|
|
|
var siteId = "test-e4-safety-" + Guid.NewGuid().ToString("N").Substring(0, 8);
|
|
var notificationId = Guid.NewGuid();
|
|
|
|
await SeedSmtpConfigAsync();
|
|
await SeedNotificationAsync(notificationId, siteId);
|
|
|
|
var adapter = new SingleOutcomeAdapter(DeliveryOutcome.Success("ops@example.com"));
|
|
var serviceProvider = BuildNotificationDispatcherProvider(adapter);
|
|
var throwingWriter = new ThrowingCentralAuditWriter();
|
|
|
|
var actor = Sys.ActorOf(Props.Create(() => new NotificationOutboxActor(
|
|
serviceProvider,
|
|
new NotificationOutboxOptions
|
|
{
|
|
DispatchInterval = TimeSpan.FromHours(1),
|
|
PurgeInterval = TimeSpan.FromDays(1),
|
|
},
|
|
(ICentralAuditWriter)throwingWriter,
|
|
NullLogger<NotificationOutboxActor>.Instance)));
|
|
|
|
actor.Tell(InternalMessages.DispatchTick.Instance);
|
|
|
|
// Notifications table reflects the successful delivery even though
|
|
// every audit write threw — the central direct-write writer
|
|
// catches/logs internally and the dispatcher catches defensively too
|
|
// (alog.md §13).
|
|
await AwaitAssertAsync(async () =>
|
|
{
|
|
await using var ctx = CreateContext();
|
|
var row = await ctx.Notifications.SingleAsync(
|
|
n => n.NotificationId == notificationId.ToString("D"));
|
|
Assert.Equal(NotificationStatus.Delivered, row.Status);
|
|
Assert.NotNull(row.DeliveredAt);
|
|
}, TimeSpan.FromSeconds(15));
|
|
|
|
// AwaitAssert, not a bare Assert: the dispatcher persists the delivery
|
|
// state BEFORE emitting the audit rows (DeliverOneAsync updates the
|
|
// row, then emits Attempted, then the terminal), so seeing Delivered
|
|
// above orders nothing with respect to the audit write — on a loaded
|
|
// parallel run the post-write continuation can land after the poll that
|
|
// observed Delivered, producing a spurious "saw 0". The bounded wait
|
|
// keeps the assertion's force: the writer must actually be invoked
|
|
// within the timeout or the test fails exactly as before.
|
|
await AwaitAssertAsync(
|
|
() =>
|
|
{
|
|
Assert.True(throwingWriter.Attempts >= 1,
|
|
$"Expected dispatcher to attempt audit write at least once; saw {throwingWriter.Attempts}.");
|
|
return Task.CompletedTask;
|
|
},
|
|
TimeSpan.FromSeconds(15));
|
|
}
|
|
|
|
// ---------------------------------------------------------------------
|
|
// Test infrastructure
|
|
// ---------------------------------------------------------------------
|
|
|
|
private ScadaBridgeDbContext CreateContext()
|
|
{
|
|
var options = new DbContextOptionsBuilder<ScadaBridgeDbContext>()
|
|
.UseSqlServer(_fixture.ConnectionString)
|
|
.ConfigureWarnings(w => w.Ignore(
|
|
Microsoft.EntityFrameworkCore.Diagnostics.RelationalEventId.PendingModelChangesWarning))
|
|
.Options;
|
|
return new ScadaBridgeDbContext(options);
|
|
}
|
|
|
|
private IServiceProvider BuildNotificationDispatcherProvider(
|
|
INotificationDeliveryAdapter adapter)
|
|
{
|
|
var services = new ServiceCollection();
|
|
services.AddDbContext<ScadaBridgeDbContext>(opts =>
|
|
opts.UseSqlServer(_fixture.ConnectionString)
|
|
.ConfigureWarnings(w => w.Ignore(
|
|
Microsoft.EntityFrameworkCore.Diagnostics.RelationalEventId.PendingModelChangesWarning)));
|
|
services.AddScoped<INotificationOutboxRepository>(sp =>
|
|
new NotificationOutboxRepository(sp.GetRequiredService<ScadaBridgeDbContext>()));
|
|
services.AddScoped<INotificationRepository>(sp =>
|
|
new NotificationRepository(sp.GetRequiredService<ScadaBridgeDbContext>()));
|
|
services.AddScoped<INotificationDeliveryAdapter>(_ => adapter);
|
|
return services.BuildServiceProvider();
|
|
}
|
|
|
|
private async Task SeedSmtpConfigAsync()
|
|
{
|
|
await using var ctx = CreateContext();
|
|
// NO-002: dispatcher clamps non-positive RetryDelay to the 1-minute fallback;
|
|
// use 1 ms so a transient outcome's NextAttemptAt is still effectively due.
|
|
ctx.SmtpConfigurations.Add(new SmtpConfiguration(
|
|
"smtp.example.com", "Basic", "noreply@example.com")
|
|
{
|
|
MaxRetries = 5,
|
|
RetryDelay = TimeSpan.FromMilliseconds(1),
|
|
});
|
|
await ctx.SaveChangesAsync();
|
|
}
|
|
|
|
private async Task SeedNotificationAsync(Guid notificationId, string siteId)
|
|
{
|
|
await using var ctx = CreateContext();
|
|
ctx.Notifications.Add(new Notification(
|
|
notificationId.ToString("D"),
|
|
NotificationType.Email,
|
|
"ops-team",
|
|
"Safety subject",
|
|
"Safety body",
|
|
siteId)
|
|
{
|
|
SourceInstanceId = "Plant.Pump42",
|
|
SourceScript = "AlarmScript",
|
|
CreatedAt = DateTimeOffset.UtcNow.AddMinutes(-1),
|
|
});
|
|
await ctx.SaveChangesAsync();
|
|
}
|
|
|
|
/// <summary>
|
|
/// Single-outcome adapter — returns the same <see cref="DeliveryOutcome"/>
|
|
/// for every call. Used by the dispatcher safety test where we only need
|
|
/// one happy-path delivery.
|
|
/// </summary>
|
|
private sealed class SingleOutcomeAdapter : INotificationDeliveryAdapter
|
|
{
|
|
private readonly DeliveryOutcome _outcome;
|
|
public SingleOutcomeAdapter(DeliveryOutcome outcome) { _outcome = outcome; }
|
|
public NotificationType Type => NotificationType.Email;
|
|
public Task<DeliveryOutcome> DeliverAsync(
|
|
Notification notification, CancellationToken cancellationToken = default)
|
|
=> Task.FromResult(_outcome);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Builds an in-memory TestHost mirroring the production inbound-API
|
|
/// pipeline order. The supplied <paramref name="writer"/> stands in for
|
|
/// the production <see cref="ICentralAuditWriter"/> so the safety test can
|
|
/// install the always-throwing variant without standing up any DB.
|
|
/// </summary>
|
|
private static async Task<IHost> BuildInboundApiHostAsync(
|
|
ICentralAuditWriter writer, int endpointStatus)
|
|
{
|
|
var hostBuilder = new HostBuilder()
|
|
.ConfigureWebHost(webBuilder =>
|
|
{
|
|
webBuilder
|
|
.UseTestServer()
|
|
.ConfigureServices(services =>
|
|
{
|
|
services.AddSingleton(writer);
|
|
services.AddRouting();
|
|
services.AddAuthorization();
|
|
services.AddAuthentication("TestScheme")
|
|
.AddScheme<Microsoft.AspNetCore.Authentication.AuthenticationSchemeOptions,
|
|
AlwaysAuthenticatedHandler>("TestScheme", _ => { });
|
|
})
|
|
.Configure(app =>
|
|
{
|
|
app.UseRouting();
|
|
app.UseAuthentication();
|
|
app.UseAuthorization();
|
|
app.UseAuditWriteMiddleware();
|
|
app.UseEndpoints(endpoints =>
|
|
{
|
|
endpoints.MapPost("/api/{methodName}", async ctx =>
|
|
{
|
|
ctx.Items[AuditWriteMiddleware.AuditActorItemKey] = "safety-actor";
|
|
ctx.Response.StatusCode = endpointStatus;
|
|
await ctx.Response.WriteAsync("ok");
|
|
});
|
|
});
|
|
});
|
|
});
|
|
return await hostBuilder.StartAsync();
|
|
}
|
|
|
|
private sealed class AlwaysAuthenticatedHandler
|
|
: Microsoft.AspNetCore.Authentication.AuthenticationHandler<
|
|
Microsoft.AspNetCore.Authentication.AuthenticationSchemeOptions>
|
|
{
|
|
public AlwaysAuthenticatedHandler(
|
|
IOptionsMonitor<Microsoft.AspNetCore.Authentication.AuthenticationSchemeOptions> options,
|
|
Microsoft.Extensions.Logging.ILoggerFactory logger,
|
|
UrlEncoder encoder)
|
|
: base(options, logger, encoder) { }
|
|
|
|
protected override Task<Microsoft.AspNetCore.Authentication.AuthenticateResult>
|
|
HandleAuthenticateAsync()
|
|
{
|
|
var identity = new ClaimsIdentity(
|
|
new[] { new Claim(ClaimTypes.Name, "framework-user") }, "TestScheme");
|
|
var principal = new ClaimsPrincipal(identity);
|
|
var ticket = new Microsoft.AspNetCore.Authentication.AuthenticationTicket(
|
|
principal, "TestScheme");
|
|
return Task.FromResult(
|
|
Microsoft.AspNetCore.Authentication.AuthenticateResult.Success(ticket));
|
|
}
|
|
}
|
|
}
|