diff --git a/SW.Bitween.Api/Resources/Accounts/Create.cs b/SW.Bitween.Api/Resources/Accounts/Create.cs index 83dd0ec8..62f1b0ed 100644 --- a/SW.Bitween.Api/Resources/Accounts/Create.cs +++ b/SW.Bitween.Api/Resources/Accounts/Create.cs @@ -76,7 +76,11 @@ public Validate(BitweenOptions bitweenOptions) RuleFor(i => i.Name).NotEmpty(); RuleFor(i => i.Email).NotEmpty(); RuleFor(i => i.Password).Password().When(_ => !bitweenOptions.DisableEmailPasswordLogin); - RuleFor(i => i.Role).NotNull(); + // The handler prefers explicit role ids and only falls back to the legacy coarse + // Role, so demanding Role unconditionally rejected the payload the UI actually + // sends — adding a member failed outright. Still required when no ids were sent, + // which is what keeps an account from being created with no roles at all. + RuleFor(i => i.Role).NotNull().When(i => i.RoleIds is not { Count: > 0 }); } } } diff --git a/SW.Bitween.Api/Resources/Accounts/Login.cs b/SW.Bitween.Api/Resources/Accounts/Login.cs index e47bc8dd..fea4829e 100644 --- a/SW.Bitween.Api/Resources/Accounts/Login.cs +++ b/SW.Bitween.Api/Resources/Accounts/Login.cs @@ -141,7 +141,12 @@ public async Task Handle(UserLogin request) $"Please try again in {minutes} minute{(minutes == 1 ? "" : "s")}."); } + // An account can have no stored password at all — that is how one created while + // Microsoft-only sign-in was on looks, and that setting can be turned back off. + // Verify dereferences the stored hash, so without this the attempt is a 500 from + // an unauthenticated endpoint instead of an ordinary failed sign-in. if (request.Password == null || + string.IsNullOrEmpty(account.Password) || !SecurePasswordHasher.Verify(request.Password, account.Password)) { // Atomic DB-side update so concurrent wrong-password attempts can't read the diff --git a/SW.Bitween.Api/Resources/Adapters/GetProperties.cs b/SW.Bitween.Api/Resources/Adapters/GetProperties.cs index 0eef79df..52fa523e 100644 --- a/SW.Bitween.Api/Resources/Adapters/GetProperties.cs +++ b/SW.Bitween.Api/Resources/Adapters/GetProperties.cs @@ -13,15 +13,22 @@ public class GetProperties : IGetHandler { private readonly IServerlessService serverless; private readonly NativeAdapterDiscoveryService _nativeAdapterDiscovery; + private readonly BitweenDbContext dbContext; + private readonly RequestContext requestContext; - public GetProperties(IServerlessService serverless, NativeAdapterDiscoveryService nativeAdapterDiscovery) + public GetProperties(IServerlessService serverless, NativeAdapterDiscoveryService nativeAdapterDiscovery, + BitweenDbContext dbContext, RequestContext requestContext) { this.serverless = serverless; _nativeAdapterDiscovery = nativeAdapterDiscovery; + this.dbContext = dbContext; + this.requestContext = requestContext; } async public Task Handle(string key) { + await requestContext.EnsurePermission(dbContext, Model.Permissions.Subscriptions.View); + var decodedKey = Uri.UnescapeDataString(key); // Check if it's a native adapter diff --git a/SW.Bitween.Api/Resources/Adapters/GetStartupValues.cs b/SW.Bitween.Api/Resources/Adapters/GetStartupValues.cs index 3f878c58..09642da8 100644 --- a/SW.Bitween.Api/Resources/Adapters/GetStartupValues.cs +++ b/SW.Bitween.Api/Resources/Adapters/GetStartupValues.cs @@ -13,17 +13,24 @@ public class GetStartupValues : IGetHandler> Handle(string key) { + await requestContext.EnsurePermission(dbContext, Model.Permissions.Subscriptions.View); + var decodedKey = Uri.UnescapeDataString(key); IDictionary startupValues = new Dictionary(); diff --git a/SW.Bitween.Api/Resources/Adapters/Metadata.cs b/SW.Bitween.Api/Resources/Adapters/Metadata.cs index cfebb597..cfd78b8d 100644 --- a/SW.Bitween.Api/Resources/Adapters/Metadata.cs +++ b/SW.Bitween.Api/Resources/Adapters/Metadata.cs @@ -11,20 +11,28 @@ public class Metadata : IGetHandler private readonly ServerlessOptions _serverlessOptions; private readonly ICloudFilesService _cloudFilesService; private readonly NativeAdapterDiscoveryService _nativeAdapterDiscovery; + private readonly BitweenDbContext _dbContext; + private readonly RequestContext _requestContext; public Metadata( ServerlessOptions serverlessOptions, ICloudFilesService cloudFilesService, - NativeAdapterDiscoveryService nativeAdapterDiscovery + NativeAdapterDiscoveryService nativeAdapterDiscovery, + BitweenDbContext dbContext, + RequestContext requestContext ) { _serverlessOptions = serverlessOptions; _cloudFilesService = cloudFilesService; _nativeAdapterDiscovery = nativeAdapterDiscovery; + _dbContext = dbContext; + _requestContext = requestContext; } public async Task Handle(string key) { + await _requestContext.EnsurePermission(_dbContext, Model.Permissions.Subscriptions.View); + var decodedKey = Uri.UnescapeDataString(key); if (decodedKey.StartsWith(NativeAdapterDiscoveryService.NativePrefix, StringComparison.OrdinalIgnoreCase)) diff --git a/SW.Bitween.Api/Resources/Adapters/Search.cs b/SW.Bitween.Api/Resources/Adapters/Search.cs index ee84d76a..eb096181 100644 --- a/SW.Bitween.Api/Resources/Adapters/Search.cs +++ b/SW.Bitween.Api/Resources/Adapters/Search.cs @@ -11,18 +11,25 @@ public class Search : IQueryHandler private readonly ServerlessOptions _serverlessOptions; private readonly ICloudFilesService _cloudFilesService; private readonly NativeAdapterDiscoveryService _nativeAdapterDiscovery; + private readonly BitweenDbContext _dbContext; + private readonly RequestContext _requestContext; public Search(ServerlessOptions serverlessOptions, ICloudFilesService cloudFilesService, - NativeAdapterDiscoveryService nativeAdapterDiscovery) + NativeAdapterDiscoveryService nativeAdapterDiscovery, BitweenDbContext dbContext, + RequestContext requestContext) { _serverlessOptions = serverlessOptions; _cloudFilesService = cloudFilesService; _nativeAdapterDiscovery = nativeAdapterDiscovery; + _dbContext = dbContext; + _requestContext = requestContext; } public async Task Handle(AdapterSearchRequest request) { + await _requestContext.EnsurePermission(_dbContext, Model.Permissions.Subscriptions.View); + // Get native adapters first var nativeAdapters = _nativeAdapterDiscovery.GetNativeAdapters(request.Prefix).ToList(); diff --git a/SW.Bitween.Api/Resources/Adapters/SearchVersioned.cs b/SW.Bitween.Api/Resources/Adapters/SearchVersioned.cs index 1f4528d4..5d29ae43 100644 --- a/SW.Bitween.Api/Resources/Adapters/SearchVersioned.cs +++ b/SW.Bitween.Api/Resources/Adapters/SearchVersioned.cs @@ -13,18 +13,25 @@ public class SearchVersioned : IQueryHandler private readonly ServerlessOptions _serverlessOptions; private readonly ICloudFilesService _cloudFilesService; private readonly NativeAdapterDiscoveryService _nativeAdapterDiscovery; + private readonly BitweenDbContext _dbContext; + private readonly RequestContext _requestContext; public SearchVersioned(ServerlessOptions serverlessOptions, ICloudFilesService cloudFilesService, - NativeAdapterDiscoveryService nativeAdapterDiscovery) + NativeAdapterDiscoveryService nativeAdapterDiscovery, BitweenDbContext dbContext, + RequestContext requestContext) { _serverlessOptions = serverlessOptions; _cloudFilesService = cloudFilesService; _nativeAdapterDiscovery = nativeAdapterDiscovery; + _dbContext = dbContext; + _requestContext = requestContext; } public async Task Handle(AdapterSearchRequest request) { + await _requestContext.EnsurePermission(_dbContext, Model.Permissions.Subscriptions.View); + var index = _serverlessOptions.AdapterRemotePath.Length + 1; // Get native adapters first (they don't have versions) diff --git a/SW.Bitween.Api/Resources/Documents/Update.cs b/SW.Bitween.Api/Resources/Documents/Update.cs index a30b8829..32f5f89c 100644 --- a/SW.Bitween.Api/Resources/Documents/Update.cs +++ b/SW.Bitween.Api/Resources/Documents/Update.cs @@ -91,6 +91,12 @@ public async Task Handle(int key, DocumentUpdate model) // properties, so it silently no-ops on these two (verified empirically). entity.SetName(model.Name); entity.SetCode(code); + // The route key is what identifies the type; the body carries an Id too, and + // SetProperties copies it straight onto the tracked entity. A caller that omits it + // sends 0, which EF rejects as an attempt to change a primary key — a 500 for a + // request that was perfectly well formed. Normalising it here makes the copy a no-op + // whatever the body said. + model.Id = key; _dbContext.Entry(entity).SetProperties(model); trail.SetAfter(entity); diff --git a/SW.Bitween.Api/Resources/Xchanges/StatusList.cs b/SW.Bitween.Api/Resources/Xchanges/StatusList.cs index fbba5057..9edfe6d9 100644 --- a/SW.Bitween.Api/Resources/Xchanges/StatusList.cs +++ b/SW.Bitween.Api/Resources/Xchanges/StatusList.cs @@ -9,17 +9,27 @@ namespace SW.Bitween.Resources.Xchanges [HandlerName("statuslist")] public class StatusList : ISearchyHandler { - public Task Handle(SearchyRequest searchyRequest, bool lookup = false, string searchPhrase = null) + private readonly BitweenDbContext dbContext; + private readonly RequestContext requestContext; + + public StatusList(BitweenDbContext dbContext, RequestContext requestContext) + { + this.dbContext = dbContext; + this.requestContext = requestContext; + } + + public async Task Handle(SearchyRequest searchyRequest, bool lookup = false, string searchPhrase = null) { - //throw new NotImplementedException(); - return Task.FromResult(new Dictionary + await requestContext.EnsurePermission(dbContext, Model.Permissions.Exchanges.View); + + return new Dictionary { {"0", "Running" }, {"1", "Success" }, {"2", "Success with bad response" }, {"3", "Failed" }, - - }); + + }; } } } diff --git a/SW.Bitween.IntegrationTests/Fixtures/BitweenFixture.cs b/SW.Bitween.IntegrationTests/Fixtures/BitweenFixture.cs index c4274999..4943f8d3 100644 --- a/SW.Bitween.IntegrationTests/Fixtures/BitweenFixture.cs +++ b/SW.Bitween.IntegrationTests/Fixtures/BitweenFixture.cs @@ -13,10 +13,13 @@ using SW.Bitween.NativeAdapters; using SW.Bitween.NativeAdapters.SmtpHandler; using SW.Bitween.PgSql; +using SW.Bitween.Services; using SW.Bus; using SW.CloudFiles.Extensions; +using SW.HttpExtensions; using SW.CloudFiles.LocalTests; using SW.PrimitiveTypes; +using SW.Scheduler; using SW.Serverless; using DotNet.Testcontainers.Builders; using DotNet.Testcontainers.Containers; @@ -31,6 +34,14 @@ namespace SW.Bitween.IntegrationTests.Fixtures; /// MailHog container, applies EF migrations, installs serverless adapters to local cloud storage, /// and builds a fully wired service provider. /// +/// +/// Every test in the collection shares this one database, so entities must not be given hand-picked +/// primary keys. Documents used to be created with literal ids chosen to be "high enough" to miss +/// the seeded rows, which worked only for as long as no two test files happened to pick the same +/// number — and when they eventually did, the pair passed in isolation and failed together, which +/// reads as a broken test rather than a collision. Let the database assign ids: for a document that +/// is new Document(null, name, format), the constructor that exists for exactly this. +/// public sealed class BitweenFixture : IAsyncLifetime { private readonly PostgreSqlContainer _postgres = new PostgreSqlBuilder().Build(); @@ -74,6 +85,11 @@ public async Task InitializeAsync() .ConfigureAppConfiguration(cfg => cfg.AddInMemoryCollection(new Dictionary { ["ConnectionStrings:RabbitMQ"] = _rabbitMq.GetConnectionString(), + // Signing material for the tokens the login handler issues. Test-only values — + // the key just has to be long enough for the algorithm to accept it. + ["Token:Key"] = "integration-test-signing-key-not-used-anywhere-else-0123456789", + ["Token:Issuer"] = "bitween-tests", + ["Token:Audience"] = "bitween-tests", })) .ConfigureServices((ctx, services) => { @@ -83,8 +99,22 @@ public async Task InitializeAsync() StorageProvider = "LocalTests", DatabaseType = "PgSql", BusDefaultQueuePrefetch = 10, + AdminCredentials = "configured-admin:configured-password", + JwtExpiryMinutes = 30, + // A passphrase has to exist or secret settings refuse to be stored at + // all, which would make the encryption path untestable. + SettingsEncryptionKey = "integration-test-settings-passphrase", }); + services.AddSingleton(new ThemeOptions()); + services.AddSingleton(); + services.AddSingleton(); + + // The login handler writes its refresh token to a response cookie, so it needs + // an HttpContext to exist. Nothing else in these tests goes through HTTP. + services.AddHttpContextAccessor(); + services.AddJwtTokenParameters(); + services.AddMemoryCache(); services.AddScoped(); @@ -120,8 +150,14 @@ public async Task InitializeAsync() services.AddScoped(); services.AddScoped(); + // See RecordingScheduleRepository: the create/update handlers need a scheduler + // to construct, and a real Quartz store would fire background jobs mid-test. + services.AddSingleton(); + services.AddScoped(); + services.AddSingleton(); services.AddScoped(); + services.AddScoped(); services.AddScoped(); services.AddScoped(); services.AddScoped(); diff --git a/SW.Bitween.IntegrationTests/Fixtures/RecordingScheduleRepository.cs b/SW.Bitween.IntegrationTests/Fixtures/RecordingScheduleRepository.cs new file mode 100644 index 00000000..01f0492e --- /dev/null +++ b/SW.Bitween.IntegrationTests/Fixtures/RecordingScheduleRepository.cs @@ -0,0 +1,84 @@ +using System; +using System.Collections.Concurrent; +using System.Collections.Generic; +using System.Threading.Tasks; +using SW.Scheduler; + +namespace SW.Bitween.IntegrationTests.Fixtures; + +/// +/// Stands in for Quartz, recording what would have been scheduled instead of scheduling it. +/// +/// +/// The handlers that create and update integrations register their schedules through this, so +/// without an implementation they can't be constructed at all. A real Quartz job store would work +/// — the migrations create its tables — but it would put a background scheduler behind every test +/// in the collection, firing real jobs against shared state at unpredictable moments. Whether +/// Quartz itself schedules correctly is a separate question, and the receiving and retry tests +/// already answer it by driving the jobs directly. +/// +internal sealed class RecordingScheduleRepository : IScheduleRepository +{ + /// Schedule keys currently registered, so a test can assert on them if it needs to. + public ConcurrentDictionary Scheduled { get; } = new(); + + public Task Schedule(TParam param, string cronExpression, string scheduleKey, + ScheduleConfig? config = null) where TScheduler : IScheduledJob + { + Scheduled[scheduleKey] = cronExpression; + return Task.CompletedTask; + } + + public Task Schedule(string cronExpression, ScheduleConfig? config = null) + where TScheduler : IScheduledJob + { + Scheduled[typeof(TScheduler).FullName!] = cronExpression; + return Task.CompletedTask; + } + + public Task ScheduleOnce(TParam param, DateTime? runAt = null, + ScheduleConfig? config = null) where TScheduler : IScheduledJob + => Task.FromResult(Guid.NewGuid().ToString("N")); + + public Task RescheduleJob(string scheduleKey, string newCronExpression) + where TScheduler : IScheduledJob + { + Scheduled[scheduleKey] = newCronExpression; + return Task.CompletedTask; + } + + public Task RescheduleJob(string newCronExpression) where TScheduler : IScheduledJob + { + Scheduled[typeof(TScheduler).FullName!] = newCronExpression; + return Task.CompletedTask; + } + + public Task UnscheduleJob(string scheduleKey) where TScheduler : IScheduledJob + { + Scheduled.TryRemove(scheduleKey, out _); + return Task.CompletedTask; + } + + public Task UnscheduleJob() where TScheduler : IScheduledJob + { + Scheduled.TryRemove(typeof(TScheduler).FullName!, out _); + return Task.CompletedTask; + } + + public Task PauseJob(string scheduleKey) where TScheduler : IScheduledJob + => Task.CompletedTask; + + public Task PauseJob() where TScheduler : IScheduledJob => Task.CompletedTask; + + public Task ResumeJob(string scheduleKey) where TScheduler : IScheduledJob + => Task.CompletedTask; + + public Task ResumeJob() where TScheduler : IScheduledJob => Task.CompletedTask; + + public Task ScheduleIfNotExists(TParam param, string cronExpression, + string scheduleKey, ScheduleConfig? config = null) where TScheduler : IScheduledJob + => Task.FromResult(Scheduled.TryAdd(scheduleKey, cronExpression)); + + /// Job discovery happens at startup against the real scheduler; nothing here needs it. + public IEnumerable GetJobDefinitions() => []; +} diff --git a/SW.Bitween.IntegrationTests/Fixtures/TestRequestContext.cs b/SW.Bitween.IntegrationTests/Fixtures/TestRequestContext.cs index a788ed4f..e7ede85e 100644 --- a/SW.Bitween.IntegrationTests/Fixtures/TestRequestContext.cs +++ b/SW.Bitween.IntegrationTests/Fixtures/TestRequestContext.cs @@ -1,5 +1,7 @@ using System.Security.Claims; +using System.Threading.Tasks; using Microsoft.Extensions.DependencyInjection; +using SW.Bitween.Domain.Accounts; using SW.PrimitiveTypes; namespace SW.Bitween.IntegrationTests.Fixtures; @@ -18,4 +20,45 @@ public static RequestContext Superuser(this AsyncServiceScope scope) [new Claim(Bitween.RequestContextExtensions.SuperuserClaim, "true")], "integration-test"))); return ctx; } + + /// + /// Signs in as a real account, so guards resolve that account's actual roles from the database. + /// The counterpart to : use this whenever the guard itself is what the + /// test is about, since the superuser claim grants the whole catalog and would hide a denial. + /// + public static RequestContext As(this AsyncServiceScope scope, int accountId) + { + var ctx = scope.ServiceProvider.GetRequiredService(); + ctx.Set(new ClaimsPrincipal(new ClaimsIdentity( + [new Claim(ClaimTypes.NameIdentifier, accountId.ToString())], "integration-test"))); + return ctx; + } + + /// + /// Creates a fresh Viewer account and signs in as it, for tests that need a real denial rather + /// than a contrived one. A new account each time because the collection shares a database and + /// email is unique; the caller gets the id back for assertions. + /// + public static async Task AsNewViewer(this AsyncServiceScope scope, string label) + { + var db = scope.ServiceProvider.GetRequiredService(); + + var viewer = new Account("Viewer", $"{label}@test.local", "hash", AccountRole.Viewer); + db.Set().Add(viewer); + await db.SaveChangesAsync(); + + db.Set().Add(new AccountRoleLink(viewer.Id, Role.ViewerId)); + await db.SaveChangesAsync(); + + scope.As(viewer.Id); + return viewer.Id; + } + + /// Signed in, but with no account behind the token — grants must resolve to nothing. + public static RequestContext AsAnonymous(this AsyncServiceScope scope) + { + var ctx = scope.ServiceProvider.GetRequiredService(); + ctx.Set(new ClaimsPrincipal(new ClaimsIdentity([], "integration-test"))); + return ctx; + } } diff --git a/SW.Bitween.IntegrationTests/Tests/AccountRecoveryTests.cs b/SW.Bitween.IntegrationTests/Tests/AccountRecoveryTests.cs new file mode 100644 index 00000000..111ecb44 --- /dev/null +++ b/SW.Bitween.IntegrationTests/Tests/AccountRecoveryTests.cs @@ -0,0 +1,140 @@ +using System; +using System.Threading.Tasks; +using Microsoft.AspNetCore.Http; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.DependencyInjection; +using SW.Bitween.Domain.Accounts; +using SW.Bitween.IntegrationTests.Fixtures; +using SW.Bitween.Model; +using SW.PrimitiveTypes; +using Xunit; + +namespace SW.Bitween.IntegrationTests.Tests; + +/// +/// Getting a user back in when they can't sign in themselves. +/// +/// +/// Bitween sends no mail, so there is no self-service reset link — an administrator acting on +/// someone's behalf is the only route back in, which makes it worth proving it actually works +/// end to end rather than assuming. These tests also pin down that resetting a password and +/// clearing a lockout are two separate actions, because that is genuinely surprising: an admin who +/// resets a locked-out user's password and stops there has not let them back in. +/// +[Collection("Bitween")] +public class AccountRecoveryTests +{ + private readonly BitweenFixture _fixture; + + public AccountRecoveryTests(BitweenFixture fixture) + { + _fixture = fixture; + } + + private const string OldPassword = "Old-Password-1!"; + private const string NewPassword = "Brand-New-Password-2!"; + + private async Task CreateAccount(string email, params int[] roleIds) + { + await using var scope = _fixture.CreateScope(); + var db = scope.ServiceProvider.GetRequiredService(); + var account = new Account("Recovery Test", email, SecurePasswordHasher.Hash(OldPassword), + AccountRole.Member); + db.Set().Add(account); + await db.SaveChangesAsync(); + + foreach (var roleId in roleIds) + db.Set().Add(new AccountRoleLink(account.Id, roleId)); + await db.SaveChangesAsync(); + + return account; + } + + private async Task Login(string email, string password) + { + await using var scope = _fixture.CreateScope(); + scope.ServiceProvider.GetRequiredService().HttpContext = new DefaultHttpContext(); + var handler = ActivatorUtilities.CreateInstance(scope.ServiceProvider); + return await handler.Handle(new UserLogin { Username = email, Password = password }); + } + + [Fact] + public async Task An_administrator_can_set_someone_elses_password() + { + var target = await CreateAccount("reset-target@test.local"); + var admin = await CreateAccount("reset-admin@test.local", Role.AdministratorId); + + await using (var scope = _fixture.CreateScope()) + { + scope.As(admin.Id); + var handler = ActivatorUtilities.CreateInstance(scope.ServiceProvider); + await handler.Handle(target.Id, new SetAccountPasswordModel { Password = NewPassword }); + } + + // The reset is only real if the new password actually opens the door. + Assert.NotNull(await Login("reset-target@test.local", NewPassword)); + await Assert.ThrowsAsync(() => Login("reset-target@test.local", OldPassword)); + } + + [Fact] + public async Task A_member_cannot_set_another_persons_password() + { + var target = await CreateAccount("victim@test.local"); + var member = await CreateAccount("nosy-member@test.local", Role.MemberId); + + await using var scope = _fixture.CreateScope(); + scope.As(member.Id); + var handler = ActivatorUtilities.CreateInstance(scope.ServiceProvider); + + // Users.Edit belongs to administrators; without this a Member could take over any account. + await Assert.ThrowsAsync(() => + handler.Handle(target.Id, new SetAccountPasswordModel { Password = NewPassword })); + } + + [Fact] + public async Task Setting_your_own_password_is_refused() + { + var admin = await CreateAccount("self-reset@test.local", Role.AdministratorId); + + await using var scope = _fixture.CreateScope(); + scope.As(admin.Id); + var handler = ActivatorUtilities.CreateInstance(scope.ServiceProvider); + + // Changing your own goes through ChangePassword, which demands the current one — otherwise + // an unattended signed-in session is enough to take the account over permanently. + var ex = await Assert.ThrowsAsync(() => + handler.Handle(admin.Id, new SetAccountPasswordModel { Password = NewPassword })); + Assert.StartsWith("USE_CHANGE_PASSWORD", ex.Message); + } + + [Fact] + public async Task Resetting_a_locked_out_users_password_does_not_by_itself_let_them_back_in() + { + var target = await CreateAccount("locked-out@test.local"); + var admin = await CreateAccount("unlock-admin@test.local", Role.AdministratorId); + + for (var attempt = 0; attempt < 5; attempt++) + await Assert.ThrowsAsync(() => Login("locked-out@test.local", "wrong")); + + await using (var scope = _fixture.CreateScope()) + { + scope.As(admin.Id); + var handler = ActivatorUtilities.CreateInstance(scope.ServiceProvider); + await handler.Handle(target.Id, new SetAccountPasswordModel { Password = NewPassword }); + } + + // Still locked: SetPassword rehashes the password and nothing else. An administrator who + // stops here has done half the job and the user is still shut out. + var ex = await Assert.ThrowsAsync(() => Login("locked-out@test.local", NewPassword)); + Assert.Contains("locked", ex.Message, StringComparison.OrdinalIgnoreCase); + + await using (var scope = _fixture.CreateScope()) + { + scope.As(admin.Id); + var handler = ActivatorUtilities.CreateInstance(scope.ServiceProvider); + await handler.Handle(target.Id, new UnlockAccountModel()); + } + + Assert.NotNull(await Login("locked-out@test.local", NewPassword)); + } +} diff --git a/SW.Bitween.IntegrationTests/Tests/AggregationTests.cs b/SW.Bitween.IntegrationTests/Tests/AggregationTests.cs index f40b6cf7..9171eff6 100644 --- a/SW.Bitween.IntegrationTests/Tests/AggregationTests.cs +++ b/SW.Bitween.IntegrationTests/Tests/AggregationTests.cs @@ -31,8 +31,9 @@ public async Task Aggregation_job_creates_one_xchange_from_successful_source_xch var cache = _fixture.App.Services.GetRequiredService(); // Source subscription whose Xchanges will be aggregated - var sourceDoc = new Document(6003, "Agg Source Doc"); + var sourceDoc = new Document(null, "Agg Source Doc", DocumentFormat.Json); db.Set().Add(sourceDoc); + await db.SaveChangesAsync(); var sourceSub = new Subscription("Agg Source", sourceDoc.Id); sourceSub.Inactive = false; db.Set().Add(sourceSub); @@ -83,8 +84,9 @@ public async Task Aggregation_job_skips_already_aggregated_xchanges() var job = scope.ServiceProvider.GetRequiredService(); var cache = _fixture.App.Services.GetRequiredService(); - var sourceDoc = new Document(6004, "Agg Source Doc 2"); + var sourceDoc = new Document(null, "Agg Source Doc 2", DocumentFormat.Json); db.Set().Add(sourceDoc); + await db.SaveChangesAsync(); var sourceSub = new Subscription("Agg Source 2", sourceDoc.Id); sourceSub.Inactive = false; db.Set().Add(sourceSub); @@ -137,8 +139,9 @@ public async Task Aggregation_job_does_nothing_for_inactive_subscription() var db = scope.ServiceProvider.GetRequiredService(); var job = scope.ServiceProvider.GetRequiredService(); - var sourceDoc = new Document(6005, "Inactive Agg Source Doc"); + var sourceDoc = new Document(null, "Inactive Agg Source Doc", DocumentFormat.Json); db.Set().Add(sourceDoc); + await db.SaveChangesAsync(); var sourceSub = new Subscription("Inactive Agg Source", sourceDoc.Id); sourceSub.Inactive = false; db.Set().Add(sourceSub); diff --git a/SW.Bitween.IntegrationTests/Tests/ApiGatewayTests.cs b/SW.Bitween.IntegrationTests/Tests/ApiGatewayTests.cs new file mode 100644 index 00000000..45976b7e --- /dev/null +++ b/SW.Bitween.IntegrationTests/Tests/ApiGatewayTests.cs @@ -0,0 +1,262 @@ +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.DependencyInjection; +using SW.Bitween.Domain; +using SW.Bitween.Domain.Gateway; +using SW.Bitween.IntegrationTests.Fixtures; +using SW.Bitween.Model; +using SW.PrimitiveTypes; +using Xunit; + +namespace SW.Bitween.IntegrationTests.Tests; + +/// +/// The gateway a partner calls, and the attachments that decide what their call runs. +/// +/// +/// An API gateway is a URL handed to an outside company, and an attachment is the rule that says +/// "when this partner calls it, run that integration". Both halves fail quietly when they are +/// wrong: a url name that cannot appear in a path saves fine and 404s only when the partner +/// finally tries it, and an attachment pointing at the wrong kind of integration looks configured +/// from every screen. +/// +[Collection("Bitween")] +public class ApiGatewayTests +{ + private readonly BitweenFixture _fixture; + + public ApiGatewayTests(BitweenFixture fixture) + { + _fixture = fixture; + } + + private static int _seq; + private static string Unique(string prefix) => $"{prefix}-{Interlocked.Increment(ref _seq)}"; + + private async Task CreateGateway(string urlName) + { + await using var scope = _fixture.CreateScope(); + scope.Superuser(); + var handler = ActivatorUtilities.CreateInstance(scope.ServiceProvider); + return (int)await handler.Handle(new ApiGatewayCreate { Name = Unique("Gateway"), UrlName = urlName }); + } + + private async Task AddPartner(int gatewayId, ApiGatewayPartnerCreate model) + { + await using var scope = _fixture.CreateScope(); + scope.Superuser(); + var handler = ActivatorUtilities.CreateInstance(scope.ServiceProvider); + await handler.Handle(gatewayId, model); + } + + /// A partner, an information type, and an integration of the type attachments demand. + private async Task<(int partnerId, int documentId, int subscriptionId)> Groundwork() + { + await using var scope = _fixture.CreateScope(); + var db = scope.ServiceProvider.GetRequiredService(); + + var partner = new Partner(Unique("Gateway partner")); + db.Set().Add(partner); + var document = new Document(null, Unique("Gateway doc"), DocumentFormat.Json); + db.Set().Add(document); + await db.SaveChangesAsync(); + + var subscription = new Subscription(Unique("Gateway integration"), document.Id, + SubscriptionType.GatewayApiCall); + db.Set().Add(subscription); + await db.SaveChangesAsync(); + + return (partner.Id, document.Id, subscription.Id); + } + + [Theory] + [InlineData("order sync")] // the one that actually happens — a space + [InlineData("Order-Sync")] // upper case, which the route match is not + [InlineData("orders/sync")] // a second path segment + [InlineData("-orders")] + public async Task A_url_name_that_cannot_appear_in_a_path_is_refused(string urlName) + { + // Partners call /api/Gateway/{urlName}/sync. Anything needing escaping there produces a + // gateway that reads as configured on every screen and cannot be reached — and the URL + // the partner is given to copy is the broken one. + var ex = await Assert.ThrowsAsync(() => CreateGateway(urlName)); + Assert.StartsWith("GATEWAY_URL_NAME_INVALID", ex.Message); + } + + [Theory] + [InlineData("orders")] + [InlineData("order-sync")] + [InlineData("order_sync_v2")] + [InlineData("orders2")] + public async Task A_usable_url_name_is_accepted(string urlName) + { + // The guard has to stay narrow: refusing a legitimate name blocks a gateway from + // existing at all, with the error pointing at the name rather than the rule. + var id = await CreateGateway(urlName); + Assert.True(id > 0); + } + + [Fact] + public async Task Attaching_a_partner_demands_an_integration_of_the_gateway_kind() + { + var (partnerId, documentId, _) = await Groundwork(); + var gatewayId = await CreateGateway(Unique("gw").ToLowerInvariant()); + + int wrongKindId; + await using (var scope = _fixture.CreateScope()) + { + var db = scope.ServiceProvider.GetRequiredService(); + // A perfectly good integration — of a kind that is started by its own schedule, not + // by a partner calling in. + var receiving = new Subscription(Unique("Scheduled"), documentId); + db.Set().Add(receiving); + await db.SaveChangesAsync(); + wrongKindId = receiving.Id; + } + + var ex = await Assert.ThrowsAsync(() => AddPartner(gatewayId, + new ApiGatewayPartnerCreate { PartnerId = partnerId, SubscriptionId = wrongKindId })); + Assert.Contains("GatewayApiCall", ex.Message); + } + + [Fact] + public async Task The_same_partner_and_integration_cannot_be_attached_twice() + { + var (partnerId, _, subscriptionId) = await Groundwork(); + var gatewayId = await CreateGateway(Unique("gw").ToLowerInvariant()); + + var attachment = new ApiGatewayPartnerCreate { PartnerId = partnerId, SubscriptionId = subscriptionId }; + await AddPartner(gatewayId, attachment); + + // Without this the second attachment wins silently and the first is unreachable — two + // rows on screen where only one can ever run. + await Assert.ThrowsAsync(() => AddPartner(gatewayId, attachment)); + } + + [Fact] + public async Task Deleting_a_gateway_takes_its_attachments_with_it() + { + var (partnerId, _, subscriptionId) = await Groundwork(); + var gatewayId = await CreateGateway(Unique("gw").ToLowerInvariant()); + await AddPartner(gatewayId, new ApiGatewayPartnerCreate + { PartnerId = partnerId, SubscriptionId = subscriptionId }); + + await using (var scope = _fixture.CreateScope()) + { + scope.Superuser(); + var handler = ActivatorUtilities.CreateInstance(scope.ServiceProvider); + await handler.Handle(gatewayId); + } + + await using var check = _fixture.CreateScope(); + var db = check.ServiceProvider.GetRequiredService(); + Assert.False(await db.Set().AnyAsync(g => g.Id == gatewayId)); + Assert.False(await db.Set().AnyAsync(p => p.ApiGatewayId == gatewayId)); + + // The integration itself is configuration in its own right and outlives the gateway — + // cascading into it would delete work the attachment merely referenced. + Assert.True(await db.Set().AnyAsync(s => s.Id == subscriptionId)); + } + + [Fact] + public async Task An_attachment_names_exactly_one_integration() + { + var (partnerId, documentId, subscriptionId) = await Groundwork(); + var gatewayId = await CreateGateway(Unique("gw").ToLowerInvariant()); + + var both = await Assert.ThrowsAsync(() => AddPartner(gatewayId, + new ApiGatewayPartnerCreate + { + PartnerId = partnerId, + SubscriptionId = subscriptionId, + NewIntegration = new InlineIntegrationCreate { Name = "Ambiguous", DocumentId = documentId }, + })); + Assert.StartsWith(GatewayLinkTarget.BothGiven, both.Message); + + var neither = await Assert.ThrowsAsync(() => AddPartner(gatewayId, + new ApiGatewayPartnerCreate { PartnerId = partnerId })); + Assert.StartsWith(GatewayLinkTarget.NeitherGiven, neither.Message); + } + + [Fact] + public async Task An_integration_defined_inline_lands_with_its_attachment_or_not_at_all() + { + var (partnerId, documentId, _) = await Groundwork(); + var gatewayId = await CreateGateway(Unique("gw").ToLowerInvariant()); + var integrationName = Unique("Defined inline"); + + await AddPartner(gatewayId, new ApiGatewayPartnerCreate + { + PartnerId = partnerId, + NewIntegration = new InlineIntegrationCreate { Name = integrationName, DocumentId = documentId }, + }); + + await using var scope = _fixture.CreateScope(); + var db = scope.ServiceProvider.GetRequiredService(); + var created = await db.Set().SingleAsync(s => s.Name == integrationName); + + Assert.Equal(SubscriptionType.GatewayApiCall, created.Type); + + // Live the moment it exists, unlike an ordinary create: it has no trigger of its own, so + // the attachment made in the same transaction is the only thing that can ever run it. + Assert.False(created.Inactive); + Assert.True(await db.Set() + .AnyAsync(p => p.ApiGatewayId == gatewayId && p.SubscriptionId == created.Id)); + } + + [Fact] + public async Task An_inline_integration_that_fails_validation_leaves_nothing_behind() + { + var (partnerId, documentId, _) = await Groundwork(); + var gatewayId = await CreateGateway(Unique("gw").ToLowerInvariant()); + var integrationName = Unique("Never committed"); + + // A bus message name with a space in it becomes a RabbitMQ routing key nothing can + // answer. This door used to skip the checks the ordinary create applies. + var ex = await Assert.ThrowsAsync(() => AddPartner(gatewayId, + new ApiGatewayPartnerCreate + { + PartnerId = partnerId, + NewIntegration = new InlineIntegrationCreate + { + Name = integrationName, + DocumentId = documentId, + ResponseMessageTypeName = "Order Placed", + }, + })); + Assert.StartsWith("INVALID_BUS_TYPE_NAME", ex.Message); + + await using var scope = _fixture.CreateScope(); + var db = scope.ServiceProvider.GetRequiredService(); + + // Both rows go in on one save, so a refusal cannot leave a half-made integration that + // nothing points at and nobody goes looking for. + Assert.False(await db.Set().AnyAsync(s => s.Name == integrationName)); + Assert.False(await db.Set().AnyAsync(p => p.ApiGatewayId == gatewayId)); + } + + [Fact] + public async Task An_inline_gateway_integration_cannot_carry_its_own_partner() + { + var (partnerId, documentId, _) = await Groundwork(); + var gatewayId = await CreateGateway(Unique("gw").ToLowerInvariant()); + + // The partner reaches a gateway integration through the attachment — which is the very + // thing being made here. One on the integration too is a second, disagreeing answer to + // the same question. + var ex = await Assert.ThrowsAsync(() => AddPartner(gatewayId, + new ApiGatewayPartnerCreate + { + PartnerId = partnerId, + NewIntegration = new InlineIntegrationCreate + { + Name = Unique("Own partner"), + DocumentId = documentId, + PartnerId = partnerId, + }, + })); + Assert.StartsWith("PARTNER_NOT_ALLOWED", ex.Message); + } +} diff --git a/SW.Bitween.IntegrationTests/Tests/BusGatewayRouteTests.cs b/SW.Bitween.IntegrationTests/Tests/BusGatewayRouteTests.cs new file mode 100644 index 00000000..fa3b8051 --- /dev/null +++ b/SW.Bitween.IntegrationTests/Tests/BusGatewayRouteTests.cs @@ -0,0 +1,257 @@ +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.DependencyInjection; +using SW.Bitween.Domain; +using SW.Bitween.Domain.Gateway; +using SW.Bitween.IntegrationTests.Fixtures; +using SW.Bitween.Model; +using SW.PrimitiveTypes; +using Xunit; + +namespace SW.Bitween.IntegrationTests.Tests; + +/// +/// Wiring up a bus gateway's routes — the configuration side of what +/// exercises at run time. +/// +/// +/// A bus gateway is bound to one information type and every route under it inherits that binding. +/// The rules here exist because the alternative is a route that saves cleanly and then never fires +/// — its integration reads a payload shaped like something else, matches nothing, and reports no +/// error at any point. +/// +[Collection("Bitween")] +public class BusGatewayRouteTests +{ + private readonly BitweenFixture _fixture; + + public BusGatewayRouteTests(BitweenFixture fixture) + { + _fixture = fixture; + } + + private static int _seq; + private static string Unique(string prefix) => $"{prefix}-{Interlocked.Increment(ref _seq)}"; + + private async Task CreateDocument() + { + await using var scope = _fixture.CreateScope(); + var db = scope.ServiceProvider.GetRequiredService(); + var document = new Document(null, Unique("Bus doc"), DocumentFormat.Json); + db.Set().Add(document); + await db.SaveChangesAsync(); + return document.Id; + } + + private async Task CreateGateway(int documentId) + { + await using var scope = _fixture.CreateScope(); + scope.Superuser(); + var handler = ActivatorUtilities.CreateInstance(scope.ServiceProvider); + return (int)await handler.Handle(new BusGatewayCreate + { Name = Unique("Bus gateway"), DocumentId = documentId }); + } + + private async Task AddRoute(int gatewayId, BusGatewayRouteCreate model) + { + await using var scope = _fixture.CreateScope(); + scope.Superuser(); + var handler = ActivatorUtilities.CreateInstance(scope.ServiceProvider); + return (int)await handler.Handle(gatewayId, model); + } + + private async Task CreateBusIntegration(int documentId) + { + await using var scope = _fixture.CreateScope(); + var db = scope.ServiceProvider.GetRequiredService(); + var subscription = new Subscription(Unique("Bus integration"), documentId, SubscriptionType.BusGateway); + db.Set().Add(subscription); + await db.SaveChangesAsync(); + return subscription.Id; + } + + [Fact] + public async Task A_gateway_has_to_name_an_information_type_that_exists() + { + await using var scope = _fixture.CreateScope(); + scope.Superuser(); + var handler = ActivatorUtilities.CreateInstance(scope.ServiceProvider); + + // The binding is fixed at creation and every route inherits it, so a wrong one here is + // not something a later edit can put right. + await Assert.ThrowsAsync(() => handler.Handle(new BusGatewayCreate + { Name = Unique("Orphan gateway"), DocumentId = 999_999 })); + } + + [Fact] + public async Task A_route_cannot_run_an_integration_bound_to_a_different_information_type() + { + var gatewayDocument = await CreateDocument(); + var otherDocument = await CreateDocument(); + var gatewayId = await CreateGateway(gatewayDocument); + var mismatched = await CreateBusIntegration(otherDocument); + + // This is the failure worth catching at save time: the route saves, the message arrives, + // the integration reads a payload shaped like something else and quietly does nothing. + var ex = await Assert.ThrowsAsync(() => AddRoute(gatewayId, + new BusGatewayRouteCreate { SubscriptionId = mismatched })); + Assert.Contains("same document", ex.Message); + } + + [Fact] + public async Task A_route_demands_an_integration_of_the_bus_gateway_kind() + { + var documentId = await CreateDocument(); + var gatewayId = await CreateGateway(documentId); + + int wrongKind; + await using (var scope = _fixture.CreateScope()) + { + var db = scope.ServiceProvider.GetRequiredService(); + // Right information type, wrong trigger — this one is started by its own schedule. + var receiving = new Subscription(Unique("Scheduled"), documentId); + db.Set().Add(receiving); + await db.SaveChangesAsync(); + wrongKind = receiving.Id; + } + + var ex = await Assert.ThrowsAsync(() => AddRoute(gatewayId, + new BusGatewayRouteCreate { SubscriptionId = wrongKind })); + Assert.Contains("BusGateway", ex.Message); + } + + [Fact] + public async Task A_route_naming_a_partner_that_does_not_exist_is_refused() + { + var documentId = await CreateDocument(); + var gatewayId = await CreateGateway(documentId); + var subscriptionId = await CreateBusIntegration(documentId); + + // The partner is where {{partner.…}} values come from at run time. A dangling id resolves + // to nothing and every token stays a literal in the outgoing request. + await Assert.ThrowsAsync(() => AddRoute(gatewayId, + new BusGatewayRouteCreate { SubscriptionId = subscriptionId, PartnerId = 999_999 })); + } + + [Fact] + public async Task An_integration_defined_inline_takes_the_gateways_information_type() + { + var documentId = await CreateDocument(); + var gatewayId = await CreateGateway(documentId); + var integrationName = Unique("Inline route integration"); + + await AddRoute(gatewayId, new BusGatewayRouteCreate + { + // DocumentId is deliberately wrong here. A bus gateway is bound to one information + // type and imposes it, so the caller's answer is not consulted — which is also why + // the mismatch this could otherwise cause is not reachable through this door. + NewIntegration = new InlineIntegrationCreate { Name = integrationName, DocumentId = 999_999 }, + }); + + await using var scope = _fixture.CreateScope(); + var db = scope.ServiceProvider.GetRequiredService(); + var created = await db.Set().SingleAsync(s => s.Name == integrationName); + + Assert.Equal(documentId, created.DocumentId); + Assert.Equal(SubscriptionType.BusGateway, created.Type); + Assert.False(created.Inactive); + Assert.True(await db.Set() + .AnyAsync(r => r.BusGatewayId == gatewayId && r.SubscriptionId == created.Id)); + } + + [Fact] + public async Task Repointing_a_route_keeps_the_same_rules() + { + var documentId = await CreateDocument(); + var otherDocument = await CreateDocument(); + var gatewayId = await CreateGateway(documentId); + var original = await CreateBusIntegration(documentId); + var replacement = await CreateBusIntegration(documentId); + var mismatched = await CreateBusIntegration(otherDocument); + + var routeId = await AddRoute(gatewayId, new BusGatewayRouteCreate { SubscriptionId = original }); + + async Task Update(BusGatewayRouteUpdate model) + { + await using var scope = _fixture.CreateScope(); + scope.Superuser(); + var handler = ActivatorUtilities.CreateInstance(scope.ServiceProvider); + await handler.Handle(gatewayId, model); + } + + // The information type check has to hold on the edit path too — otherwise every rule + // above is one save-and-edit away from being bypassed. + await Assert.ThrowsAsync(() => Update(new BusGatewayRouteUpdate + { RouteId = routeId, SubscriptionId = mismatched })); + + // Repointing always names an integration that already exists; defining one inline + // belongs to the route being created. + var neither = await Assert.ThrowsAsync(() => Update(new BusGatewayRouteUpdate + { RouteId = routeId, NewIntegration = new InlineIntegrationCreate { Name = "Too late" } })); + Assert.StartsWith(GatewayLinkTarget.NeitherGiven, neither.Message); + + await Update(new BusGatewayRouteUpdate + { + RouteId = routeId, + SubscriptionId = replacement, + MatchExpression = new OneOfSpec("channel", ["pos"]), + }); + + await using var scope = _fixture.CreateScope(); + var db = scope.ServiceProvider.GetRequiredService(); + var route = await db.Set().SingleAsync(r => r.Id == routeId); + + Assert.Equal(replacement, route.SubscriptionId); + Assert.Equal("channel is one of [pos]", route.MatchExpression.ToString()); + } + + [Fact] + public async Task Deleting_a_gateway_takes_its_routes_with_it() + { + var documentId = await CreateDocument(); + var gatewayId = await CreateGateway(documentId); + var subscriptionId = await CreateBusIntegration(documentId); + await AddRoute(gatewayId, new BusGatewayRouteCreate { SubscriptionId = subscriptionId }); + + await using (var scope = _fixture.CreateScope()) + { + scope.Superuser(); + var handler = ActivatorUtilities.CreateInstance(scope.ServiceProvider); + await handler.Handle(gatewayId); + } + + await using var check = _fixture.CreateScope(); + var db = check.ServiceProvider.GetRequiredService(); + Assert.False(await db.Set().AnyAsync(g => g.Id == gatewayId)); + Assert.False(await db.Set().AnyAsync(r => r.BusGatewayId == gatewayId)); + + // The route goes, the integration stays. It is configuration in its own right, and + // deleting it here would be silent data loss dressed up as tidying. + Assert.True(await db.Set().AnyAsync(s => s.Id == subscriptionId)); + } + + [Fact] + public async Task Removing_one_route_leaves_the_gateways_others_alone() + { + var documentId = await CreateDocument(); + var gatewayId = await CreateGateway(documentId); + var first = await AddRoute(gatewayId, + new BusGatewayRouteCreate { SubscriptionId = await CreateBusIntegration(documentId) }); + var second = await AddRoute(gatewayId, + new BusGatewayRouteCreate { SubscriptionId = await CreateBusIntegration(documentId) }); + + await using (var scope = _fixture.CreateScope()) + { + scope.Superuser(); + var handler = ActivatorUtilities.CreateInstance(scope.ServiceProvider); + await handler.Handle(gatewayId, new RemoveRouteRequest { RouteId = first }); + } + + await using var check = _fixture.CreateScope(); + var db = check.ServiceProvider.GetRequiredService(); + Assert.False(await db.Set().AnyAsync(r => r.Id == first)); + Assert.True(await db.Set().AnyAsync(r => r.Id == second)); + } +} diff --git a/SW.Bitween.IntegrationTests/Tests/DelayedRetriesTests.cs b/SW.Bitween.IntegrationTests/Tests/DelayedRetriesTests.cs index 1c3657b2..9745d12e 100644 --- a/SW.Bitween.IntegrationTests/Tests/DelayedRetriesTests.cs +++ b/SW.Bitween.IntegrationTests/Tests/DelayedRetriesTests.cs @@ -29,9 +29,9 @@ public DelayedRetriesTests(BitweenFixture fixture) }; private async Task<(Document doc, Subscription sub, Xchange xchange)> CreateSubscriptionWithXchange( - BitweenDbContext db, XchangeService xs, int docId, string name) + BitweenDbContext db, XchangeService xs, string name) { - var doc = new Document(docId, name); + var doc = new Document(null, name, DocumentFormat.Json); db.Set().Add(doc); await db.SaveChangesAsync(); @@ -54,7 +54,7 @@ public async Task Retry_throws_when_auto_retry_already_scheduled() await using var scope = _fixture.CreateScope(); var db = scope.ServiceProvider.GetRequiredService(); var xs = scope.ServiceProvider.GetRequiredService(); - var (_, _, xchange) = await CreateSubscriptionWithXchange(db, xs, 9001, "Retry Guard Doc"); + var (_, _, xchange) = await CreateSubscriptionWithXchange(db, xs, "Retry Guard Doc"); db.Set().Add(new DelayedRetry { Id = xchange.Id, On = DateTime.UtcNow.AddMinutes(5) }); await db.SaveChangesAsync(); @@ -71,7 +71,7 @@ public async Task Retry_succeeds_when_no_auto_retry_scheduled() await using var scope = _fixture.CreateScope(); var db = scope.ServiceProvider.GetRequiredService(); var xs = scope.ServiceProvider.GetRequiredService(); - var (_, _, xchange) = await CreateSubscriptionWithXchange(db, xs, 9002, "Retry OK Doc"); + var (_, _, xchange) = await CreateSubscriptionWithXchange(db, xs, "Retry OK Doc"); var retry = new SW.Bitween.Resources.Xchanges.Retry(db, xs); await retry.Handle(xchange.Id, new XchangeRetry { Reset = false }); @@ -87,8 +87,8 @@ public async Task BulkRetry_skips_ids_with_scheduled_auto_retry_and_processes_ot var db = scope.ServiceProvider.GetRequiredService(); var xs = scope.ServiceProvider.GetRequiredService(); - var (_, _, xchangeScheduled) = await CreateSubscriptionWithXchange(db, xs, 9003, "Bulk Scheduled Doc"); - var (_, _, xchangeFree) = await CreateSubscriptionWithXchange(db, xs, 9004, "Bulk Free Doc"); + var (_, _, xchangeScheduled) = await CreateSubscriptionWithXchange(db, xs, "Bulk Scheduled Doc"); + var (_, _, xchangeFree) = await CreateSubscriptionWithXchange(db, xs, "Bulk Free Doc"); db.Set().Add(new DelayedRetry { Id = xchangeScheduled.Id, On = DateTime.UtcNow.AddMinutes(5) }); await db.SaveChangesAsync(); @@ -115,7 +115,7 @@ public async Task DelayedRetries_Search_returns_expected_row() await using var scope = _fixture.CreateScope(); var db = scope.ServiceProvider.GetRequiredService(); var xs = scope.ServiceProvider.GetRequiredService(); - var (doc, sub, xchange) = await CreateSubscriptionWithXchange(db, xs, 9005, "Search Row Doc"); + var (doc, sub, xchange) = await CreateSubscriptionWithXchange(db, xs, "Search Row Doc"); var scheduledOn = DateTime.UtcNow.AddMinutes(10); db.Set().Add(new DelayedRetry { Id = xchange.Id, On = scheduledOn }); @@ -142,7 +142,7 @@ public async Task RunNow_executes_immediately_even_when_not_yet_due_and_removes_ var db = scope.ServiceProvider.GetRequiredService(); var xs = scope.ServiceProvider.GetRequiredService(); var ctx = scope.Superuser(); - var (_, _, xchange) = await CreateSubscriptionWithXchange(db, xs, 9006, "Run Now Doc"); + var (_, _, xchange) = await CreateSubscriptionWithXchange(db, xs, "Run Now Doc"); // Scheduled an hour from now — RunNow must still execute it immediately. db.Set().Add(new DelayedRetry { Id = xchange.Id, On = DateTime.UtcNow.AddHours(1) }); @@ -180,7 +180,7 @@ public async Task Xchanges_Search_includes_ScheduledRetryOn_when_delayed_retry_e await using var scope = _fixture.CreateScope(); var db = scope.ServiceProvider.GetRequiredService(); var xs = scope.ServiceProvider.GetRequiredService(); - var (_, _, xchange) = await CreateSubscriptionWithXchange(db, xs, 9007, "Xchange Search Scheduled Doc"); + var (_, _, xchange) = await CreateSubscriptionWithXchange(db, xs, "Xchange Search Scheduled Doc"); var scheduledOn = DateTime.UtcNow.AddMinutes(15); db.Set().Add(new DelayedRetry { Id = xchange.Id, On = scheduledOn }); @@ -201,7 +201,7 @@ public async Task Xchanges_Search_has_null_ScheduledRetryOn_when_no_delayed_retr await using var scope = _fixture.CreateScope(); var db = scope.ServiceProvider.GetRequiredService(); var xs = scope.ServiceProvider.GetRequiredService(); - var (_, _, xchange) = await CreateSubscriptionWithXchange(db, xs, 9008, "Xchange Search Unscheduled Doc"); + var (_, _, xchange) = await CreateSubscriptionWithXchange(db, xs, "Xchange Search Unscheduled Doc"); var search = new SW.Bitween.Resources.Xchanges.Search(db, xs, scope.Superuser()); var response = (SearchyResponse)await search.Handle(EmptySearch()); diff --git a/SW.Bitween.IntegrationTests/Tests/EntityTests.cs b/SW.Bitween.IntegrationTests/Tests/EntityTests.cs index a6a94023..b98ac7a7 100644 --- a/SW.Bitween.IntegrationTests/Tests/EntityTests.cs +++ b/SW.Bitween.IntegrationTests/Tests/EntityTests.cs @@ -29,12 +29,11 @@ public async Task Can_create_and_read_document() await using var scope = _fixture.CreateScope(); var db = scope.ServiceProvider.GetRequiredService(); - // Use high IDs to avoid PK conflicts with seeded data (AggregationDocumentId = 10001) - var document = new Document(5001, "Integration Test Doc"); + var document = new Document(null, "Integration Test Doc", DocumentFormat.Json); db.Set().Add(document); await db.SaveChangesAsync(); - var loaded = await db.Set().FirstOrDefaultAsync(d => d.Id == 5001); + var loaded = await db.Set().FirstOrDefaultAsync(d => d.Id == document.Id); Assert.NotNull(loaded); Assert.Equal("Integration Test Doc", loaded.Name); @@ -66,7 +65,7 @@ public async Task Can_create_receiving_subscription() var db = scope.ServiceProvider.GetRequiredService(); // Create a document for the subscription to reference - var document = new Document(5002, "Sub Test Doc"); + var document = new Document(null, "Sub Test Doc", DocumentFormat.Json); db.Set().Add(document); await db.SaveChangesAsync(); diff --git a/SW.Bitween.IntegrationTests/Tests/GatewayRoutingTests.cs b/SW.Bitween.IntegrationTests/Tests/GatewayRoutingTests.cs new file mode 100644 index 00000000..8d09ad97 --- /dev/null +++ b/SW.Bitween.IntegrationTests/Tests/GatewayRoutingTests.cs @@ -0,0 +1,339 @@ +using System.Collections.Generic; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.DependencyInjection; +using SW.Bitween.Domain; +using SW.Bitween.Domain.Gateway; +using SW.Bitween.IntegrationTests.Fixtures; +using SW.Bitween.Model; +using SW.PrimitiveTypes; +using Xunit; + +namespace SW.Bitween.IntegrationTests.Tests; + +/// +/// Which integrations run when a document arrives on the bus. +/// +/// +/// is the dispatcher: one message lands, and it decides — from the +/// payload alone — which integrations are handed it. Everything downstream is a consequence of +/// what it returns, and until now nothing exercised it. Both directions matter equally here: an +/// integration that stops being selected goes quiet with no error anywhere, and one that is +/// selected when it shouldn't be runs real traffic through the wrong pipeline. +/// +[Collection("Bitween")] +public class GatewayRoutingTests +{ + private readonly BitweenFixture _fixture; + + public GatewayRoutingTests(BitweenFixture fixture) + { + _fixture = fixture; + } + + private static int _seq; + private static string Unique(string prefix) => $"{prefix}-{Interlocked.Increment(ref _seq)}"; + + /// + /// A JSON information type promoting the two fields the route filters read, with its id left + /// to the database. Hand-picked ids are how tests in this shared collection collide: the + /// number has to be unclaimed by every other test file, and nothing enforces that. + /// + private static async Task OrdersDocument(BitweenDbContext db, string name) + { + var doc = new Document(null, Unique(name), DocumentFormat.Json); + doc.SetDictionaries(new Dictionary + { + ["country"] = "country", + ["channel"] = "channel", + }); + db.Set().Add(doc); + await db.SaveChangesAsync(); + return doc.Id; + } + + private static Subscription BusGatewayIntegration(string name, int documentId) + => new(name, documentId, SubscriptionType.BusGateway) { Inactive = false }; + + /// + /// Runs the dispatcher over a payload. The cache is revoked first: it is a singleton holding a + /// ten-minute snapshot, so without this a test reads configuration from before its own setup. + /// + private async Task Dispatch(int documentId, string payload) + { + await using var scope = _fixture.CreateScope(); + scope.ServiceProvider.GetRequiredService().Revoke(); + var filterService = scope.ServiceProvider.GetRequiredService(); + return await filterService.Filter(documentId, new XchangeFile(payload)); + } + + [Fact] + public async Task A_route_with_no_filter_runs_its_integration_for_every_message() + { + await using (var scope = _fixture.CreateScope()) + { + var db = scope.ServiceProvider.GetRequiredService(); + var docId = await OrdersDocument(db, "Routing catch-all"); + + var integration = BusGatewayIntegration(Unique("Catch-all handler"), docId); + db.Set().Add(integration); + var gateway = new BusGateway { Name = Unique("Catch-all bus"), DocumentId = docId }; + db.Set().Add(gateway); + await db.SaveChangesAsync(); + + // No MatchExpression at all — the "send me everything on this type" route. + db.Set().Add(new BusGatewayRoute + { + BusGatewayId = gateway.Id, + SubscriptionId = integration.Id, + }); + await db.SaveChangesAsync(); + + var result = await Dispatch(docId, "{\"country\":\"JO\",\"channel\":\"web\"}"); + Assert.Contains(result.GatewayHits, h => h.SubscriptionId == integration.Id); + + var other = await Dispatch(docId, "{\"country\":\"AE\",\"channel\":\"pos\"}"); + Assert.Contains(other.GatewayHits, h => h.SubscriptionId == integration.Id); + } + } + + [Fact] + public async Task A_route_filter_selects_only_the_messages_it_names() + { + int docId, jordan, emirates; + + await using (var scope = _fixture.CreateScope()) + { + var db = scope.ServiceProvider.GetRequiredService(); + docId = await OrdersDocument(db, "Routing by country"); + + var jordanIntegration = BusGatewayIntegration(Unique("Jordan pipeline"), docId); + var emiratesIntegration = BusGatewayIntegration(Unique("Emirates pipeline"), docId); + db.Set().AddRange(jordanIntegration, emiratesIntegration); + var gateway = new BusGateway { Name = Unique("Country bus"), DocumentId = docId }; + db.Set().Add(gateway); + await db.SaveChangesAsync(); + + jordan = jordanIntegration.Id; + emirates = emiratesIntegration.Id; + + db.Set().AddRange( + new BusGatewayRoute + { + BusGatewayId = gateway.Id, + SubscriptionId = jordan, + MatchExpression = new OneOfSpec("country", ["JO"]), + }, + new BusGatewayRoute + { + BusGatewayId = gateway.Id, + SubscriptionId = emirates, + MatchExpression = new OneOfSpec("country", ["AE"]), + }); + await db.SaveChangesAsync(); + } + + var result = await Dispatch(docId, "{\"country\":\"JO\",\"channel\":\"web\"}"); + + // Selecting is the entire job — a filter that lets everything through is the same bug as + // one that lets nothing through, and only checking both sides catches it. + Assert.Contains(result.GatewayHits, h => h.SubscriptionId == jordan); + Assert.DoesNotContain(result.GatewayHits, h => h.SubscriptionId == emirates); + } + + [Fact] + public async Task A_route_carries_its_partner_to_the_integration_it_runs() + { + int docId, integrationId, partnerId; + + await using (var scope = _fixture.CreateScope()) + { + var db = scope.ServiceProvider.GetRequiredService(); + docId = await OrdersDocument(db, "Routing with partner"); + var partner = new Partner(Unique("Routed partner")); + db.Set().Add(partner); + await db.SaveChangesAsync(); + + var integration = BusGatewayIntegration(Unique("Partner pipeline"), docId); + db.Set().Add(integration); + var gateway = new BusGateway { Name = Unique("Partner bus"), DocumentId = docId }; + db.Set().Add(gateway); + await db.SaveChangesAsync(); + + integrationId = integration.Id; + partnerId = partner.Id; + + db.Set().Add(new BusGatewayRoute + { + BusGatewayId = gateway.Id, + SubscriptionId = integrationId, + PartnerId = partnerId, + }); + await db.SaveChangesAsync(); + } + + var result = await Dispatch(docId, "{\"country\":\"JO\",\"channel\":\"web\"}"); + + // A bus gateway integration has no partner of its own, so this is the only place the + // partner can come from. Lose it here and every {{partner.…}} in its adapters stays a + // literal token — the request goes out to a URL with the placeholder still in it. + var hit = Assert.Single(result.GatewayHits, h => h.SubscriptionId == integrationId); + Assert.Equal(partnerId, hit.PartnerId); + } + + [Fact] + public async Task A_deactivated_gateway_offers_none_of_its_routes() + { + int docId, integrationId, gatewayId; + + await using (var scope = _fixture.CreateScope()) + { + var db = scope.ServiceProvider.GetRequiredService(); + docId = await OrdersDocument(db, "Routing deactivated"); + + var integration = BusGatewayIntegration(Unique("Paused pipeline"), docId); + db.Set().Add(integration); + var gateway = new BusGateway { Name = Unique("Paused bus"), DocumentId = docId }; + db.Set().Add(gateway); + await db.SaveChangesAsync(); + + integrationId = integration.Id; + gatewayId = gateway.Id; + + db.Set().Add(new BusGatewayRoute + { + BusGatewayId = gatewayId, + SubscriptionId = integrationId, + }); + await db.SaveChangesAsync(); + } + + Assert.Contains((await Dispatch(docId, "{\"country\":\"JO\"}")).GatewayHits, + h => h.SubscriptionId == integrationId); + + await using (var scope = _fixture.CreateScope()) + { + var db = scope.ServiceProvider.GetRequiredService(); + var gateway = await db.Set().SingleAsync(g => g.Id == gatewayId); + gateway.Inactive = true; + await db.SaveChangesAsync(); + } + + // This is all deactivating a bus gateway means: the message still publishes, this gateway + // just stops being one of the places it lands. Turning it off is the only alternative to + // deleting it, and deleting takes the routes with it. + Assert.DoesNotContain((await Dispatch(docId, "{\"country\":\"JO\"}")).GatewayHits, + h => h.SubscriptionId == integrationId); + } + + [Fact] + public async Task An_integration_with_its_own_entry_point_is_not_run_by_a_message_arriving() + { + int docId; + var ids = new Dictionary(); + + await using (var scope = _fixture.CreateScope()) + { + var db = scope.ServiceProvider.GetRequiredService(); + docId = await OrdersDocument(db, "Routing entry points"); + var partner = new Partner(Unique("Entry point partner")); + db.Set().Add(partner); + await db.SaveChangesAsync(); + + var subscriptions = new Dictionary + { + // Started by its gateway's routes. + [SubscriptionType.BusGateway] = + new("Entry bus gateway", docId, SubscriptionType.BusGateway) { Inactive = false }, + // Started by a partner calling the gateway it is attached to. + [SubscriptionType.GatewayApiCall] = + new("Entry gateway api", docId, SubscriptionType.GatewayApiCall) { Inactive = false }, + // Started by its schedule, through ReceivingJob. + [SubscriptionType.Receiving] = + new("Entry receiving", docId) { Inactive = false }, + // Started by its own partner posting to Xchanges/Update. + [SubscriptionType.ApiCall] = + new("Entry api call", docId, SubscriptionType.ApiCall, partner.Id) { Inactive = false }, + // The control. Without one, every assertion below would also pass if the + // subscriptions simply never reached the dispatcher — an empty result looks + // identical to a correctly filtered one. + [SubscriptionType.Internal] = + new("Entry control", docId, SubscriptionType.Internal, partner.Id) { Inactive = false }, + }; + + db.Set().AddRange(subscriptions.Values); + await db.SaveChangesAsync(); + + foreach (var (type, subscription) in subscriptions) + ids[type] = subscription.Id; + } + + var result = await Dispatch(docId, "{\"country\":\"JO\",\"channel\":\"web\"}"); + + Assert.Contains(ids[SubscriptionType.Internal], result.Hits); + + // Each of these four is started by something that decides it should run at all. Matching + // them here as well ran them a second time on traffic addressed to nobody: a scheduled job + // publishing the very message type it is bound to fed itself forever, and an ApiCall + // integration belonging to one partner ran on another partner's message. The second run + // also arrived without the partner its entry point would have passed. + foreach (var (type, id) in ids.Where(p => p.Key != SubscriptionType.Internal)) + Assert.DoesNotContain(id, result.Hits); + } + + [Fact] + public async Task An_internal_integration_still_runs_when_its_document_arrives() + { + int docId, matching, filteredOut; + + await using (var scope = _fixture.CreateScope()) + { + var db = scope.ServiceProvider.GetRequiredService(); + docId = await OrdersDocument(db, "Routing internal"); + var partner = new Partner(Unique("Internal partner")); + db.Set().Add(partner); + await db.SaveChangesAsync(); + + var open = new Subscription("Internal open", docId, SubscriptionType.Internal, partner.Id) + { Inactive = false }; + var narrowed = new Subscription("Internal narrowed", docId, SubscriptionType.Internal, partner.Id) + { Inactive = false }; + narrowed.SetMatchExpression(new OneOfSpec("channel", ["pos"])); + + db.Set().AddRange(open, narrowed); + await db.SaveChangesAsync(); + + matching = open.Id; + filteredOut = narrowed.Id; + } + + var result = await Dispatch(docId, "{\"country\":\"JO\",\"channel\":\"web\"}"); + + // The counterweight to the test above: reacting to a document of its type arriving is the + // whole definition of an Internal integration — it has no other trigger, so a guard that + // over-reaches silences it with nothing to show for it. + Assert.Contains(matching, result.Hits); + Assert.DoesNotContain(filteredOut, result.Hits); + } + + [Fact] + public async Task The_promoted_properties_are_read_off_the_payload_as_sent() + { + int docId; + + await using (var scope = _fixture.CreateScope()) + { + var db = scope.ServiceProvider.GetRequiredService(); + docId = await OrdersDocument(db, "Routing promoted properties"); + } + + var result = await Dispatch(docId, "{\"country\":\"Acme Retail\",\"channel\":\"web\"}"); + + // Case is preserved. These values are what every screen displays, and lower-casing them + // here to pair with a search term listed an order for "Acme Retail" as "acme retail". + Assert.Equal("Acme Retail", result.Properties["country"]); + Assert.Equal("web", result.Properties["channel"]); + } +} diff --git a/SW.Bitween.IntegrationTests/Tests/InformationTypeTests.cs b/SW.Bitween.IntegrationTests/Tests/InformationTypeTests.cs new file mode 100644 index 00000000..4f59cfd7 --- /dev/null +++ b/SW.Bitween.IntegrationTests/Tests/InformationTypeTests.cs @@ -0,0 +1,232 @@ +using System.Collections.Generic; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.DependencyInjection; +using SW.Bitween.Domain; +using SW.Bitween.IntegrationTests.Fixtures; +using SW.Bitween.Model; +using SW.PrimitiveTypes; +using Xunit; + +namespace SW.Bitween.IntegrationTests.Tests; + +/// +/// Information types — the kinds of document that flow through Bitween, and the shape every +/// integration is configured against. +/// +/// +/// Almost every rule here is about names that look distinct to a person but are the same thing to +/// a machine. Two types called "Invoice" and "invoice" are indistinguishable in every list that +/// shows them; two publishing as "OrderPlaced" and "orderplaced" are one message on the wire, +/// because the routing key is lower-cased at both ends. Neither produces an error at the moment it +/// is created — the damage shows up later as messages arriving somewhere nobody meant them to. +/// +[Collection("Bitween")] +public class InformationTypeTests +{ + private readonly BitweenFixture _fixture; + + public InformationTypeTests(BitweenFixture fixture) + { + _fixture = fixture; + } + + private static int _seq; + private static string Unique(string prefix) => $"{prefix}-{Interlocked.Increment(ref _seq)}"; + + private async Task Create(DocumentCreate model) + { + await using var scope = _fixture.CreateScope(); + scope.Superuser(); + var handler = ActivatorUtilities.CreateInstance(scope.ServiceProvider); + return (int)await handler.Handle(model); + } + + private async Task Update(int id, DocumentUpdate model) + { + await using var scope = _fixture.CreateScope(); + scope.Superuser(); + var handler = ActivatorUtilities.CreateInstance(scope.ServiceProvider); + await handler.Handle(id, model); + } + + private async Task Stored(int id) + { + await using var scope = _fixture.CreateScope(); + var db = scope.ServiceProvider.GetRequiredService(); + return await db.Set().AsNoTracking().SingleAsync(d => d.Id == id); + } + + [Fact] + public async Task Two_types_cannot_share_a_name_even_in_a_different_case() + { + var name = Unique("Invoice"); + await Create(new DocumentCreate { Name = name }); + + var ex = await Assert.ThrowsAsync(() => + Create(new DocumentCreate { Name = name.ToUpperInvariant() })); + + // Allowing both leaves a list nobody can read, and then neither can be saved again: + // Update refuses the name it already has. + Assert.StartsWith("NAME_TAKEN", ex.Message); + } + + [Fact] + public async Task Two_types_cannot_publish_under_the_same_bus_name_in_a_different_case() + { + var busName = Unique("OrderPlaced"); + await Create(new DocumentCreate + { + Name = Unique("Bus original"), + BusEnabled = true, + BusMessageTypeName = busName, + }); + + var ex = await Assert.ThrowsAsync(() => Create(new DocumentCreate + { + Name = Unique("Bus duplicate"), + BusEnabled = true, + BusMessageTypeName = busName.ToLowerInvariant(), + })); + + // The one that actually bites: the routing key is lower-cased by both the publisher and + // the consumer, so these are one message. Letting both exist meant every message published + // under either name reached both gateways, with nothing anywhere saying so. + Assert.StartsWith("DUPLICATED_BUS_TYPE_NAME", ex.Message); + } + + [Fact] + public async Task A_code_is_claimed_once() + { + var code = "INV_" + Interlocked.Increment(ref _seq); + await Create(new DocumentCreate { Name = Unique("Coded"), Code = code }); + + var ex = await Assert.ThrowsAsync(() => + Create(new DocumentCreate { Name = Unique("Coded again"), Code = code })); + + Assert.StartsWith("CODE_TAKEN", ex.Message); + } + + [Fact] + public async Task Renaming_a_type_actually_persists() + { + var id = await Create(new DocumentCreate { Name = Unique("Before rename") }); + var newName = Unique("After rename"); + var newCode = "REN_" + Interlocked.Increment(ref _seq); + + await Update(id, new DocumentUpdate { Name = newName, Code = newCode }); + + // Name and Code have private setters, and the bulk property copy only writes public ones — + // so the rename used to be accepted, reported as saved, and silently discarded. The handler + // now sets both explicitly; this is here so that cannot come back. + var stored = await Stored(id); + Assert.Equal(newName, stored.Name); + Assert.Equal(newCode, stored.Code); + } + + [Fact] + public async Task A_type_can_keep_its_own_name_when_something_else_is_edited() + { + var name = Unique("Keeps its name"); + var id = await Create(new DocumentCreate { Name = name }); + + // The uniqueness check has to exclude the row being edited, or the second save of any + // type fails on the name it already has. + await Update(id, new DocumentUpdate { Name = name, DuplicateInterval = 60 }); + + Assert.Equal(60, (await Stored(id)).DuplicateInterval); + } + + [Fact] + public async Task Sending_no_promoted_properties_clears_them_rather_than_failing() + { + var id = await Create(new DocumentCreate + { + Name = Unique("Had properties"), + PromotedProperties = [new KeyAndValue { Key = "country", Value = "country" }], + }); + Assert.Single((await Stored(id)).PromotedProperties); + + // An absent list means none. Left implicit this threw ArgumentNullException — a 500 for a + // request whose meaning the API had simply never decided. + await Update(id, new DocumentUpdate { Name = Unique("Now has none") }); + + Assert.Empty((await Stored(id)).PromotedProperties); + } + + [Theory] + [InlineData("$.order.total")] + [InlineData("order.total")] + [InlineData("items[0].sku")] + public async Task A_usable_json_path_is_accepted(string path) + { + var id = await Create(new DocumentCreate + { + Name = Unique("Json paths"), + DocumentFormat = DocumentFormat.Json, + PromotedProperties = [new KeyAndValue { Key = "value", Value = path }], + }); + + Assert.Equal(path, (await Stored(id)).PromotedProperties["value"]); + } + + [Fact] + public async Task A_promoted_property_that_could_never_match_is_refused() + { + // These are read against every payload that arrives. A path that cannot resolve does not + // fail loudly — the property is simply blank on every exchange, and on every screen. + var ex = await Assert.ThrowsAsync(() => Create(new DocumentCreate + { + Name = Unique("Bad path"), + DocumentFormat = DocumentFormat.Json, + PromotedProperties = [new KeyAndValue { Key = "total", Value = "not a path!" }], + })); + Assert.StartsWith("INVALID_PROMOTED_PROPERTY_PATH", ex.Message); + + var blank = await Assert.ThrowsAsync(() => Create(new DocumentCreate + { + Name = Unique("Blank path"), + PromotedProperties = [new KeyAndValue { Key = "total", Value = " " }], + })); + Assert.StartsWith("INVALID_PROMOTED_PROPERTY_VALUE", blank.Message); + } + + [Fact] + public async Task The_same_promoted_key_cannot_be_defined_twice() + { + // The pair collapses into one dictionary entry, so the second silently wins and the + // configuration on screen is not the one being used. + var ex = await Assert.ThrowsAsync(() => Create(new DocumentCreate + { + Name = Unique("Duplicate keys"), + PromotedProperties = + [ + new KeyAndValue { Key = "country", Value = "shipping.country" }, + new KeyAndValue { Key = "Country", Value = "billing.country" }, + ], + })); + + Assert.StartsWith("DUPLICATE_PROMOTED_PROPERTY_KEY", ex.Message); + } + + [Fact] + public async Task Turning_the_bus_off_drops_the_message_name_with_it() + { + var id = await Create(new DocumentCreate + { + Name = Unique("Bus toggled"), + BusEnabled = true, + BusMessageTypeName = Unique("ToggledMessage"), + }); + + await Update(id, new DocumentUpdate { Name = Unique("Bus off"), BusEnabled = false }); + + // A name left behind on a disabled type still occupies the namespace, so the next type + // that wants it is refused for a reason nothing on screen explains. + var stored = await Stored(id); + Assert.False(stored.BusEnabled); + Assert.True(string.IsNullOrEmpty(stored.BusMessageTypeName)); + } +} diff --git a/SW.Bitween.IntegrationTests/Tests/LoginTests.cs b/SW.Bitween.IntegrationTests/Tests/LoginTests.cs new file mode 100644 index 00000000..fa931414 --- /dev/null +++ b/SW.Bitween.IntegrationTests/Tests/LoginTests.cs @@ -0,0 +1,188 @@ +using System; +using System.Linq; +using System.Threading.Tasks; +using Microsoft.AspNetCore.Http; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.DependencyInjection; +using SW.Bitween.Domain.Accounts; +using SW.Bitween.IntegrationTests.Fixtures; +using SW.Bitween.Model; +using SW.PrimitiveTypes; +using Xunit; + +namespace SW.Bitween.IntegrationTests.Tests; + +/// +/// The account login handler — the one door into the system, and the only place a password is +/// checked. +/// +/// +/// Worth testing in full because most of the handler is refusals rather than the happy path, and a +/// refusal that silently stops refusing looks exactly like everything working. The lockout counter +/// especially: it is applied with a single database-side UPDATE so that concurrent wrong guesses +/// can't each read the same count and overwrite one another, which would let an attacker stay +/// permanently one attempt below the threshold. +/// +[Collection("Bitween")] +public class LoginTests +{ + private readonly BitweenFixture _fixture; + + public LoginTests(BitweenFixture fixture) + { + _fixture = fixture; + } + + private const string GoodPassword = "Correct-Horse-9!"; + + /// + /// One attempt in its own scope, because that is what a real request is. Sharing a scope across + /// attempts would share a DbContext, and the lockout counter is written with a database-side + /// UPDATE that the change tracker never sees — so a second attempt would read a stale account + /// and the lockout would look broken when it isn't. + /// + private async Task Login(string email, string password) + { + await using var scope = _fixture.CreateScope(); + var accessor = scope.ServiceProvider.GetRequiredService(); + accessor.HttpContext = new DefaultHttpContext(); + var handler = ActivatorUtilities.CreateInstance(scope.ServiceProvider); + return await handler.Handle(new UserLogin { Username = email, Password = password }); + } + + private async Task CreateAccount(string email, string password = GoodPassword, + bool disabled = false) + { + await using var scope = _fixture.CreateScope(); + var db = scope.ServiceProvider.GetRequiredService(); + // A null password is the real state of an invited account and of a Microsoft-only + // instance: the row exists purely to be matched by address, with nothing to verify against. + var account = new Account("Login Test", email, + password is null ? null : SecurePasswordHasher.Hash(password), AccountRole.Member); + if (disabled) account.SetDisabled(true); + db.Set().Add(account); + await db.SaveChangesAsync(); + return account; + } + + /// Reads the account back through a fresh context, so it reflects what is committed. + private async Task Reload(int accountId) + { + await using var scope = _fixture.CreateScope(); + var db = scope.ServiceProvider.GetRequiredService(); + return await db.Set().AsNoTracking().SingleAsync(a => a.Id == accountId); + } + + [Fact] + public async Task Correct_password_returns_a_token() + { + await CreateAccount("good@test.local"); + + var result = await Login("good@test.local", GoodPassword); + + var jwt = result.GetType().GetProperty("Jwt")?.GetValue(result) as string; + Assert.False(string.IsNullOrWhiteSpace(jwt)); + } + + [Fact] + public async Task Email_matching_ignores_case() + { + await CreateAccount("mixedcase@test.local"); + + // Nobody types their address the same way twice; a case-sensitive lookup would lock people + // out of accounts that exist. + var result = await Login("MixedCase@Test.Local", GoodPassword); + + Assert.NotNull(result); + } + + [Fact] + public async Task Wrong_password_is_refused() + { + await CreateAccount("wrongpass@test.local"); + + await Assert.ThrowsAsync(() => Login("wrongpass@test.local", "not-the-password")); + } + + [Fact] + public async Task An_unknown_email_is_refused_the_same_way_as_a_wrong_password() + { + var unknown = await Assert.ThrowsAsync(() => Login("nobody-here@test.local", GoodPassword)); + + await CreateAccount("exists@test.local"); + var wrongPassword = await Assert.ThrowsAsync(() => Login("exists@test.local", "not-the-password")); + + // Identical wording on purpose: a different message would tell an attacker which addresses + // are registered, turning the login form into an account directory. + Assert.Equal(unknown.Message, wrongPassword.Message); + } + + [Fact] + public async Task A_disabled_account_cannot_sign_in() + { + await CreateAccount("disabled@test.local", disabled: true); + + // This is also what holds invitations shut: an invite creates the account up front, disabled + // and password-less, and nothing else stands between the invitee and the system. + var ex = await Assert.ThrowsAsync(() => Login("disabled@test.local", GoodPassword)); + + Assert.Contains("disabled", ex.Message, StringComparison.OrdinalIgnoreCase); + } + + [Fact] + public async Task An_account_that_has_no_password_cannot_be_signed_into() + { + await CreateAccount("nopassword@test.local", password: null); + + // The account an invitation creates, before the invitee has set anything. With nothing + // stored to compare against, a verification that treats "no password" as "matches" hands + // a token to anyone who can guess the address. + await Assert.ThrowsAsync(() => Login("nopassword@test.local", "")); + await Assert.ThrowsAsync(() => Login("nopassword@test.local", "anything")); + } + + [Fact] + public async Task An_empty_password_is_refused_for_an_account_that_has_one() + { + await CreateAccount("emptyattempt@test.local"); + + // The other half: submitting nothing must be a failed attempt, not a skipped check. + await Assert.ThrowsAsync(() => Login("emptyattempt@test.local", "")); + } + + [Fact] + public async Task Repeated_wrong_passwords_lock_the_account() + { + var account = await CreateAccount("lockout@test.local"); + + for (var attempt = 0; attempt < 5; attempt++) + { + await Assert.ThrowsAsync(() => Login("lockout@test.local", "wrong")); + } + + // The point of the lockout: even the real password stops working while it holds. + var ex = await Assert.ThrowsAsync(() => Login("lockout@test.local", GoodPassword)); + Assert.Contains("locked", ex.Message, StringComparison.OrdinalIgnoreCase); + + var stored = await Reload(account.Id); + Assert.NotNull(stored.LockoutEnd); + } + + [Fact] + public async Task A_successful_login_clears_earlier_failures() + { + var account = await CreateAccount("resets@test.local"); + + for (var attempt = 0; attempt < 3; attempt++) + { + await Assert.ThrowsAsync(() => Login("resets@test.local", "wrong")); + } + + await Login("resets@test.local", GoodPassword); + + // Otherwise failures accumulate across weeks and the lockout eventually fires on a user who + // has done nothing wrong. + var stored = await Reload(account.Id); + Assert.Equal(0, stored.FailedLoginCount); + } +} diff --git a/SW.Bitween.IntegrationTests/Tests/NotifierTests.cs b/SW.Bitween.IntegrationTests/Tests/NotifierTests.cs new file mode 100644 index 00000000..0633c5ac --- /dev/null +++ b/SW.Bitween.IntegrationTests/Tests/NotifierTests.cs @@ -0,0 +1,197 @@ +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.DependencyInjection; +using SW.Bitween.Domain; +using SW.Bitween.IntegrationTests.Fixtures; +using SW.Bitween.Model; +using SW.PrimitiveTypes; +using Xunit; + +namespace SW.Bitween.IntegrationTests.Tests; + +/// +/// Notifiers — the alerts sent when an exchange succeeds or fails. +/// +/// +/// A notifier points at the integrations it watches rather than the other way round, so nothing +/// holds a foreign key to it and deleting one is always allowed. That makes the interesting cases +/// the quiet ones: a notifier whose watch list or handler settings are dropped on an edit goes on +/// existing while silently alerting on nothing. +/// +[Collection("Bitween")] +public class NotifierTests +{ + private readonly BitweenFixture _fixture; + + public NotifierTests(BitweenFixture fixture) + { + _fixture = fixture; + } + + private static int _seq; + private static string Unique(string prefix) => $"{prefix}-{Interlocked.Increment(ref _seq)}"; + + private async Task Create(string name) + { + await using var scope = _fixture.CreateScope(); + scope.Superuser(); + var handler = ActivatorUtilities.CreateInstance(scope.ServiceProvider); + return (int)await handler.Handle(new NotifierCreate { Name = name }); + } + + private async Task Update(int id, NotifierUpdate model) + { + await using var scope = _fixture.CreateScope(); + scope.Superuser(); + var handler = ActivatorUtilities.CreateInstance(scope.ServiceProvider); + await handler.Handle(id, model); + } + + private async Task Stored(int id) + { + await using var scope = _fixture.CreateScope(); + var db = scope.ServiceProvider.GetRequiredService(); + return await db.Set().AsNoTracking().SingleAsync(n => n.Id == id); + } + + [Fact] + public async Task A_new_notifier_starts_active() + { + var id = await Create(Unique("Fresh notifier")); + + // Unlike an integration, which is born inactive because it is created half-configured. + // A notifier has nothing to configure before it can run. + Assert.False((await Stored(id)).Inactive); + } + + [Fact] + public async Task Updating_with_no_handler_properties_clears_them_rather_than_failing() + { + var id = await Create(Unique("Had settings")); + await Update(id, new NotifierUpdate + { + Name = Unique("Had settings"), + HandlerId = "native:smtp", + HandlerProperties = [new KeyAndValue { Key = "Host", Value = "smtp.example.com" }], + }); + Assert.Single((await Stored(id)).HandlerProperties); + + // An absent list means none, as it does for a document's promoted properties. Left + // implicit this threw ArgumentNullException — a 500 for a well-formed request. + await Update(id, new NotifierUpdate { Name = Unique("No settings"), HandlerId = "native:smtp" }); + + Assert.Empty((await Stored(id)).HandlerProperties); + } + + [Fact] + public async Task An_edit_that_names_no_handler_keeps_the_one_it_had() + { + var id = await Create(Unique("Keeps handler")); + await Update(id, new NotifierUpdate { Name = Unique("With handler"), HandlerId = "native:smtp" }); + + await Update(id, new NotifierUpdate { Name = Unique("Renamed"), HandlerId = null }); + + // Losing the handler would leave a notifier that looks configured and can deliver nothing. + Assert.Equal("native:smtp", (await Stored(id)).HandlerId); + } + + [Fact] + public async Task The_watch_list_is_replaced_by_what_the_edit_sends() + { + var id = await Create(Unique("Watcher")); + int first, second; + + await using (var scope = _fixture.CreateScope()) + { + var db = scope.ServiceProvider.GetRequiredService(); + var document = new Document(null, Unique("Notifier doc"), DocumentFormat.Json); + db.Set().Add(document); + await db.SaveChangesAsync(); + + var a = new Subscription(Unique("Watched A"), document.Id); + var b = new Subscription(Unique("Watched B"), document.Id); + db.Set().AddRange(a, b); + await db.SaveChangesAsync(); + first = a.Id; + second = b.Id; + } + + await Update(id, new NotifierUpdate + { + Name = Unique("Watcher"), + HandlerId = "native:smtp", + RunOnSubscriptions = [new NotifierSubscription { Id = first }, new NotifierSubscription { Id = second }], + }); + Assert.Equal([first, second], (await Stored(id)).RunOnSubscriptions); + + await Update(id, new NotifierUpdate + { + Name = Unique("Watcher"), + HandlerId = "native:smtp", + RunOnSubscriptions = [new NotifierSubscription { Id = second }], + }); + + // The list is a replacement, not an addition — otherwise an integration can never be + // taken off a notifier once added. + Assert.Equal([second], (await Stored(id)).RunOnSubscriptions); + } + + [Fact] + public async Task Deleting_a_notifier_takes_its_watch_list_with_it() + { + var id = await Create(Unique("Doomed")); + int subscriptionId; + + await using (var scope = _fixture.CreateScope()) + { + var db = scope.ServiceProvider.GetRequiredService(); + var document = new Document(null, Unique("Doomed doc"), DocumentFormat.Json); + db.Set().Add(document); + await db.SaveChangesAsync(); + var subscription = new Subscription(Unique("Still wanted"), document.Id); + db.Set().Add(subscription); + await db.SaveChangesAsync(); + subscriptionId = subscription.Id; + } + + await Update(id, new NotifierUpdate + { + Name = Unique("Doomed"), + HandlerId = "native:smtp", + RunOnSubscriptions = [new NotifierSubscription { Id = subscriptionId }], + }); + + await using (var scope = _fixture.CreateScope()) + { + scope.Superuser(); + var handler = ActivatorUtilities.CreateInstance(scope.ServiceProvider); + await handler.Handle(id); + } + + await using var check = _fixture.CreateScope(); + var checkDb = check.ServiceProvider.GetRequiredService(); + Assert.False(await checkDb.Set().AnyAsync(n => n.Id == id)); + + // The watch list is the notifier's own column, so it goes with it — but the integration + // it named is configuration in its own right and must survive. + Assert.True(await checkDb.Set().AnyAsync(s => s.Id == subscriptionId)); + } + + [Fact] + public async Task A_viewer_cannot_create_or_delete_a_notifier() + { + var id = await Create(Unique("Guarded notifier")); + + await using var scope = _fixture.CreateScope(); + await scope.AsNewViewer(Unique("notifier-viewer")); + + var create = ActivatorUtilities.CreateInstance(scope.ServiceProvider); + await Assert.ThrowsAsync(() => + create.Handle(new NotifierCreate { Name = "Should not exist" })); + + var delete = ActivatorUtilities.CreateInstance(scope.ServiceProvider); + await Assert.ThrowsAsync(() => delete.Handle(id)); + } +} diff --git a/SW.Bitween.IntegrationTests/Tests/PartnerTokenTests.cs b/SW.Bitween.IntegrationTests/Tests/PartnerTokenTests.cs index 5bb86af1..7ef0dad3 100644 --- a/SW.Bitween.IntegrationTests/Tests/PartnerTokenTests.cs +++ b/SW.Bitween.IntegrationTests/Tests/PartnerTokenTests.cs @@ -43,7 +43,7 @@ public async Task Subscription_own_partner_fills_handler_tokens() db.Set().Add(partner); await db.SaveChangesAsync(); - var doc = new Document(6101, "Partner Token Doc"); + var doc = new Document(null, "Partner Token Doc", DocumentFormat.Json); db.Set().Add(doc); await db.SaveChangesAsync(); @@ -85,7 +85,7 @@ public async Task Partner_handed_in_wins_over_the_subscriptions_own() db.Set().AddRange(own, routed); await db.SaveChangesAsync(); - var doc = new Document(6102, "Partner Token Doc 2"); + var doc = new Document(null, "Partner Token Doc 2", DocumentFormat.Json); db.Set().Add(doc); await db.SaveChangesAsync(); @@ -117,7 +117,7 @@ public async Task No_partner_anywhere_leaves_the_token_alone() var db = scope.ServiceProvider.GetRequiredService(); var xchangeService = scope.ServiceProvider.GetRequiredService(); - var doc = new Document(6103, "Partner Token Doc 3"); + var doc = new Document(null, "Partner Token Doc 3", DocumentFormat.Json); db.Set().Add(doc); await db.SaveChangesAsync(); diff --git a/SW.Bitween.IntegrationTests/Tests/PermissionGuardTests.cs b/SW.Bitween.IntegrationTests/Tests/PermissionGuardTests.cs new file mode 100644 index 00000000..d434903e --- /dev/null +++ b/SW.Bitween.IntegrationTests/Tests/PermissionGuardTests.cs @@ -0,0 +1,189 @@ +using System.Linq; +using System.Threading.Tasks; +using Microsoft.Extensions.DependencyInjection; +using SW.Bitween.Domain.Accounts; +using SW.Bitween.IntegrationTests.Fixtures; +using SW.Bitween.Model; +using SW.PrimitiveTypes; +using Xunit; + +namespace SW.Bitween.IntegrationTests.Tests; + +/// +/// The guards in front of the handlers, exercised against real accounts and roles rather than the +/// superuser claim every other test uses. +/// +/// +/// Two properties are load-bearing and neither is obvious from reading a handler. First, the +/// built-in roles don't store their grants — Role.SystemPermissions derives them at runtime, +/// so a Viewer's exact reach is a computed thing that could quietly change. Second, permissions are +/// resolved from the database on every call instead of being carried in the token, which is the +/// only reason revoking a role can take effect before the token expires. That second one is a +/// deliberate design decision with a real cost (a query per guarded call), so it deserves a test +/// proving the cost buys something. +/// +[Collection("Bitween")] +public class PermissionGuardTests +{ + private readonly BitweenFixture _fixture; + + public PermissionGuardTests(BitweenFixture fixture) + { + _fixture = fixture; + } + + private static async Task CreateAccount(BitweenDbContext db, string email, params int[] roleIds) + { + var account = new Account("Test User", email, "irrelevant-hash", AccountRole.Member); + db.Set().Add(account); + await db.SaveChangesAsync(); + + foreach (var roleId in roleIds) + db.Set().Add(new AccountRoleLink(account.Id, roleId)); + await db.SaveChangesAsync(); + + return account; + } + + [Fact] + public async Task Viewer_may_read_but_not_write() + { + await using var scope = _fixture.CreateScope(); + var db = scope.ServiceProvider.GetRequiredService(); + + var viewer = await CreateAccount(db, "viewer-rw@test.local", Role.ViewerId); + var ctx = scope.As(viewer.Id); + + // The read side is what a Viewer exists for. + await ctx.EnsurePermission(db, Permissions.Partners.View); + + // Every write is refused, across areas rather than just one, so this fails if the + // view-only derivation ever starts leaking a create/edit/delete key. + await Assert.ThrowsAsync(() => + ctx.EnsurePermission(db, Permissions.Partners.Create)); + await Assert.ThrowsAsync(() => + ctx.EnsurePermission(db, Permissions.Subscriptions.Edit)); + await Assert.ThrowsAsync(() => + ctx.EnsurePermission(db, Permissions.Documents.Delete)); + } + + [Fact] + public async Task Member_may_write_integrations_but_not_manage_the_team() + { + await using var scope = _fixture.CreateScope(); + var db = scope.ServiceProvider.GetRequiredService(); + + var member = await CreateAccount(db, "member-scope@test.local", Role.MemberId); + var ctx = scope.As(member.Id); + + await ctx.EnsurePermission(db, Permissions.Subscriptions.Create); + await ctx.EnsurePermission(db, Permissions.Partners.Edit); + + // Users, roles and settings are the administrator's, and this is the line that keeps a + // Member from granting themselves anything else. + await Assert.ThrowsAsync(() => + ctx.EnsurePermission(db, Permissions.Users.Create)); + await Assert.ThrowsAsync(() => + ctx.EnsurePermission(db, Permissions.Roles.Edit)); + await Assert.ThrowsAsync(() => + ctx.EnsurePermission(db, Permissions.Settings.Edit)); + } + + [Fact] + public async Task Administrator_holds_the_whole_catalog() + { + await using var scope = _fixture.CreateScope(); + var db = scope.ServiceProvider.GetRequiredService(); + + var admin = await CreateAccount(db, "admin-all@test.local", Role.AdministratorId); + var granted = await RequestContextExtensions.GetPermissionsOf(db, admin.Id); + + Assert.Equal(PermissionCatalog.AllKeys.ToHashSet(), granted); + } + + [Fact] + public async Task Revoking_a_role_takes_effect_without_a_new_token() + { + await using var scope = _fixture.CreateScope(); + var db = scope.ServiceProvider.GetRequiredService(); + + var account = await CreateAccount(db, "revoked@test.local", Role.MemberId); + var ctx = scope.As(account.Id); + + await ctx.EnsurePermission(db, Permissions.Subscriptions.Create); + + // Same RequestContext, same claims — only the database changed. If grants were baked into + // the token this would keep passing until it expired. + var link = db.Set().Single(l => l.AccountId == account.Id && l.RoleId == Role.MemberId); + db.Set().Remove(link); + await db.SaveChangesAsync(); + + await Assert.ThrowsAsync(() => + ctx.EnsurePermission(db, Permissions.Subscriptions.Create)); + } + + [Fact] + public async Task Granting_a_role_also_takes_effect_immediately() + { + await using var scope = _fixture.CreateScope(); + var db = scope.ServiceProvider.GetRequiredService(); + + var account = await CreateAccount(db, "granted@test.local"); + var ctx = scope.As(account.Id); + + await Assert.ThrowsAsync(() => + ctx.EnsurePermission(db, Permissions.Subscriptions.Create)); + + db.Set().Add(new AccountRoleLink(account.Id, Role.MemberId)); + await db.SaveChangesAsync(); + + await ctx.EnsurePermission(db, Permissions.Subscriptions.Create); + } + + [Fact] + public async Task An_account_with_no_roles_is_granted_nothing() + { + await using var scope = _fixture.CreateScope(); + var db = scope.ServiceProvider.GetRequiredService(); + + var account = await CreateAccount(db, "no-roles@test.local"); + var ctx = scope.As(account.Id); + + Assert.Empty(await ctx.GetPermissions(db)); + await Assert.ThrowsAsync(() => + ctx.EnsurePermission(db, Permissions.Partners.View)); + } + + [Fact] + public async Task A_token_with_no_account_behind_it_is_refused() + { + await using var scope = _fixture.CreateScope(); + var db = scope.ServiceProvider.GetRequiredService(); + + // Fails closed rather than falling through to an empty grant set: a token that carries no + // identifiable account is a broken token, not an unprivileged one. + var ctx = scope.AsAnonymous(); + + await Assert.ThrowsAsync(() => ctx.GetPermissions(db)); + } + + [Fact] + public async Task A_custom_role_grants_exactly_what_it_stores() + { + await using var scope = _fixture.CreateScope(); + var db = scope.ServiceProvider.GetRequiredService(); + + // Non-system roles take their grants from the column, unlike the built-in three. + var role = new Role("Exchange watcher", "Sees exchanges, nothing else", + [Permissions.Exchanges.View]); + db.Set().Add(role); + await db.SaveChangesAsync(); + + var account = await CreateAccount(db, "custom-role@test.local", role.Id); + var ctx = scope.As(account.Id); + + await ctx.EnsurePermission(db, Permissions.Exchanges.View); + await Assert.ThrowsAsync(() => + ctx.EnsurePermission(db, Permissions.Exchanges.Operate)); + } +} diff --git a/SW.Bitween.IntegrationTests/Tests/ReceivingTests.cs b/SW.Bitween.IntegrationTests/Tests/ReceivingTests.cs index ef6900bb..689ef398 100644 --- a/SW.Bitween.IntegrationTests/Tests/ReceivingTests.cs +++ b/SW.Bitween.IntegrationTests/Tests/ReceivingTests.cs @@ -29,8 +29,9 @@ public async Task Receiving_job_creates_one_xchange_per_received_file() var job = scope.ServiceProvider.GetRequiredService(); var cache = _fixture.App.Services.GetRequiredService(); - var document = new Document(6001, "Receiving Test Doc"); + var document = new Document(null, "Receiving Test Doc", DocumentFormat.Json); db.Set().Add(document); + await db.SaveChangesAsync(); // NativeTestReceiver is matched by class name, which starts with "Native" (case-insensitive "native" prefix) var subscription = new Subscription("Receive Test", document.Id); @@ -57,8 +58,9 @@ public async Task Receiving_job_records_one_attempt_with_the_exchanges_it_create var job = scope.ServiceProvider.GetRequiredService(); var cache = _fixture.App.Services.GetRequiredService(); - var document = new Document(6006, "Receiving Attempt Doc"); + var document = new Document(null, "Receiving Attempt Doc", DocumentFormat.Json); db.Set().Add(document); + await db.SaveChangesAsync(); var subscription = new Subscription("Receive Attempt Test", document.Id); subscription.ReceiverId = nameof(NativeTestReceiver); @@ -93,8 +95,9 @@ public async Task Receiving_job_records_a_failed_attempt_when_listing_files_thro var job = scope.ServiceProvider.GetRequiredService(); var cache = _fixture.App.Services.GetRequiredService(); - var document = new Document(6007, "Receiving Failure Doc"); + var document = new Document(null, "Receiving Failure Doc", DocumentFormat.Json); db.Set().Add(document); + await db.SaveChangesAsync(); var subscription = new Subscription("Receive Failure Test", document.Id); subscription.ReceiverId = nameof(NativeFailingTestReceiver); @@ -121,8 +124,9 @@ public async Task Receiving_job_records_no_new_data_when_nothing_is_found() var job = scope.ServiceProvider.GetRequiredService(); var cache = _fixture.App.Services.GetRequiredService(); - var document = new Document(6008, "Receiving Empty Doc"); + var document = new Document(null, "Receiving Empty Doc", DocumentFormat.Json); db.Set().Add(document); + await db.SaveChangesAsync(); var subscription = new Subscription("Receive Empty Test", document.Id); subscription.ReceiverId = nameof(NativeEmptyTestReceiver); @@ -148,8 +152,9 @@ public async Task Receiving_job_does_nothing_for_inactive_subscription() var db = scope.ServiceProvider.GetRequiredService(); var job = scope.ServiceProvider.GetRequiredService(); - var document = new Document(6002, "Inactive Receiving Doc"); + var document = new Document(null, "Inactive Receiving Doc", DocumentFormat.Json); db.Set().Add(document); + await db.SaveChangesAsync(); var subscription = new Subscription("Inactive Receiver", document.Id); subscription.ReceiverId = nameof(NativeTestReceiver); diff --git a/SW.Bitween.IntegrationTests/Tests/RetryAlertServiceTests.cs b/SW.Bitween.IntegrationTests/Tests/RetryAlertServiceTests.cs index 5e445634..6254bdfb 100644 --- a/SW.Bitween.IntegrationTests/Tests/RetryAlertServiceTests.cs +++ b/SW.Bitween.IntegrationTests/Tests/RetryAlertServiceTests.cs @@ -76,7 +76,7 @@ public async Task Exhausted_budget_alert_arrives_in_MailHog_with_the_group_and_s var db = scope.ServiceProvider.GetRequiredService(); var alertService = scope.ServiceProvider.GetRequiredService(); - var doc = new Document(7201, "MailHog Alert Doc"); + var doc = new Document(null, "MailHog Alert Doc", DocumentFormat.Json); db.Set().Add(doc); await db.SaveChangesAsync(); @@ -195,7 +195,7 @@ public async Task A_failed_send_does_not_stop_a_later_delivery() var db = scope.ServiceProvider.GetRequiredService(); var alertService = scope.ServiceProvider.GetRequiredService(); - var doc = new Document(7202, "Failed Send Doc"); + var doc = new Document(null, "Failed Send Doc", DocumentFormat.Json); db.Set().Add(doc); await db.SaveChangesAsync(); diff --git a/SW.Bitween.IntegrationTests/Tests/RetryJobTests.cs b/SW.Bitween.IntegrationTests/Tests/RetryJobTests.cs index e150a9b4..7f9bbf12 100644 --- a/SW.Bitween.IntegrationTests/Tests/RetryJobTests.cs +++ b/SW.Bitween.IntegrationTests/Tests/RetryJobTests.cs @@ -82,7 +82,7 @@ public async Task RetryJob_removes_delayed_retry_when_subscription_is_missing() var xs = scope.ServiceProvider.GetRequiredService(); // Create an Xchange with no subscription (SubscriptionId remains null) - var doc = new Document(8003, "RetryJob Orphan Sub Doc"); + var doc = new Document(null, "RetryJob Orphan Sub Doc", DocumentFormat.Json); db.Set().Add(doc); await db.SaveChangesAsync(); @@ -124,7 +124,7 @@ public async Task RetryJob_drops_a_retry_whose_input_is_gone_and_still_runs_the_ var db = scope.ServiceProvider.GetRequiredService(); var xs = scope.ServiceProvider.GetRequiredService(); - var doc = new Document(8010, "RetryJob Missing Input Doc"); + var doc = new Document(null, "RetryJob Missing Input Doc", DocumentFormat.Json); db.Set().Add(doc); await db.SaveChangesAsync(); @@ -191,7 +191,7 @@ public async Task BulkRetry_handles_an_exchange_with_no_subscription() var db = scope.ServiceProvider.GetRequiredService(); var xs = scope.ServiceProvider.GetRequiredService(); - var doc = new Document(8012, "BulkRetry No Sub Doc"); + var doc = new Document(null, "BulkRetry No Sub Doc", DocumentFormat.Json); db.Set().Add(doc); await db.SaveChangesAsync(); @@ -226,7 +226,7 @@ public async Task RetryJob_processes_due_delayed_retry_and_creates_retry_xchange var db = scope.ServiceProvider.GetRequiredService(); var xs = scope.ServiceProvider.GetRequiredService(); - var doc = new Document(8001, "RetryJob Due Doc"); + var doc = new Document(null, "RetryJob Due Doc", DocumentFormat.Json); db.Set().Add(doc); await db.SaveChangesAsync(); @@ -268,12 +268,11 @@ public async Task RetryJob_processes_multiple_due_records_in_one_invocation() var xs = scope.ServiceProvider.GetRequiredService(); // Create 3 subscriptions / xchanges - var docs = new[] { 8005, 8006, 8007 }; var originalIds = new string[3]; for (var i = 0; i < 3; i++) { - var doc = new Document(docs[i], $"RetryJob Batch Doc {i}"); + var doc = new Document(null, $"RetryJob Batch Doc {i}", DocumentFormat.Json); db.Set().Add(doc); await db.SaveChangesAsync(); diff --git a/SW.Bitween.IntegrationTests/Tests/RetryPolicyTests.cs b/SW.Bitween.IntegrationTests/Tests/RetryPolicyTests.cs index 1c81406b..0a15fdc5 100644 --- a/SW.Bitween.IntegrationTests/Tests/RetryPolicyTests.cs +++ b/SW.Bitween.IntegrationTests/Tests/RetryPolicyTests.cs @@ -197,8 +197,9 @@ public async Task Cannot_delete_retry_policy_that_is_assigned_to_a_subscription( var ctx = scope.Superuser(); var (create, _, _, delete) = Handlers(db, ctx, Secrets(scope)); - var doc = new Document(7001, "Delete Guard Doc"); + var doc = new Document(null, "Delete Guard Doc", DocumentFormat.Json); db.Set().Add(doc); + await db.SaveChangesAsync(); var sub = new Subscription("Delete Guard Sub", doc.Id); db.Set().Add(sub); await db.SaveChangesAsync(); @@ -249,8 +250,9 @@ public async Task Subscription_retry_policy_id_is_persisted_and_fk_resolves() var ctx = scope.Superuser(); var (create, _, _, _) = Handlers(db, ctx, Secrets(scope)); - var doc = new Document(7002, "Sub FK Doc"); + var doc = new Document(null, "Sub FK Doc", DocumentFormat.Json); db.Set().Add(doc); + await db.SaveChangesAsync(); var sub = new Subscription("Sub with Policy", doc.Id); db.Set().Add(sub); await db.SaveChangesAsync(); @@ -277,8 +279,9 @@ public async Task Subscription_custom_retry_policy_json_is_persisted_and_reloads await using var scope = _fixture.CreateScope(); var db = scope.ServiceProvider.GetRequiredService(); - var doc = new Document(7003, "Sub Custom Policy Doc"); + var doc = new Document(null, "Sub Custom Policy Doc", DocumentFormat.Json); db.Set().Add(doc); + await db.SaveChangesAsync(); var sub = new Subscription("Sub with Custom Policy", doc.Id); db.Set().Add(sub); await db.SaveChangesAsync(); @@ -320,8 +323,9 @@ public async Task Removing_retry_policy_nullifies_subscription_fk_via_set_null_c await using var scope = _fixture.CreateScope(); var db = scope.ServiceProvider.GetRequiredService(); - var doc = new Document(7004, "Sub SetNull Doc"); + var doc = new Document(null, "Sub SetNull Doc", DocumentFormat.Json); db.Set().Add(doc); + await db.SaveChangesAsync(); var sub = new Subscription("Sub SetNull Test", doc.Id); db.Set().Add(sub); await db.SaveChangesAsync(); @@ -349,8 +353,9 @@ public async Task Group_total_is_shared_across_separate_messages_of_the_same_int await using var scope = _fixture.CreateScope(); var db = scope.ServiceProvider.GetRequiredService(); - var doc = new Document(7005, "Shared Total Doc"); + var doc = new Document(null, "Shared Total Doc", DocumentFormat.Json); db.Set().Add(doc); + await db.SaveChangesAsync(); var sub = new Subscription("Shared Total Sub", doc.Id); db.Set().Add(sub); await db.SaveChangesAsync(); @@ -396,8 +401,9 @@ public async Task Group_total_is_tracked_per_integration_not_per_policy() await using var scope = _fixture.CreateScope(); var db = scope.ServiceProvider.GetRequiredService(); - var doc = new Document(7006, "Per Integration Doc"); + var doc = new Document(null, "Per Integration Doc", DocumentFormat.Json); db.Set().Add(doc); + await db.SaveChangesAsync(); var subA = new Subscription("Per Integration Sub A", doc.Id); var subB = new Subscription("Per Integration Sub B", doc.Id); db.Set().AddRange(subA, subB); @@ -441,8 +447,9 @@ public async Task Concurrent_claims_never_exceed_the_group_total() await using var setup = _fixture.CreateScope(); var setupDb = setup.ServiceProvider.GetRequiredService(); - var doc = new Document(7010, "Concurrent Budget Doc"); + var doc = new Document(null, "Concurrent Budget Doc", DocumentFormat.Json); setupDb.Set().Add(doc); + await setupDb.SaveChangesAsync(); var sub = new Subscription("Concurrent Budget Sub", doc.Id); setupDb.Set().Add(sub); await setupDb.SaveChangesAsync(); @@ -480,7 +487,7 @@ public async Task Usage_reports_spent_budget_and_reset_clears_it() var db = scope.ServiceProvider.GetRequiredService(); var ctx = scope.Superuser(); - var doc = new Document(7007, "Usage Doc"); + var doc = new Document(null, "Usage Doc", DocumentFormat.Json); db.Set().Add(doc); await db.SaveChangesAsync(); @@ -532,7 +539,7 @@ public async Task Usage_lists_never_failed_pairs_and_skips_groups_that_cannot_ex var db = scope.ServiceProvider.GetRequiredService(); var ctx = scope.Superuser(); - var doc = new Document(7011, "Never Failed Doc"); + var doc = new Document(null, "Never Failed Doc", DocumentFormat.Json); db.Set().Add(doc); await db.SaveChangesAsync(); @@ -582,7 +589,7 @@ public async Task Reset_does_not_touch_counters_of_another_policy() var db = scope.ServiceProvider.GetRequiredService(); var ctx = scope.Superuser(); - var doc = new Document(7008, "Reset Scope Doc"); + var doc = new Document(null, "Reset Scope Doc", DocumentFormat.Json); db.Set().Add(doc); await db.SaveChangesAsync(); @@ -617,7 +624,7 @@ public async Task Removing_a_group_clears_its_spent_budget() var db = scope.ServiceProvider.GetRequiredService(); var ctx = scope.Superuser(); - var doc = new Document(7009, "Removed Group Doc"); + var doc = new Document(null, "Removed Group Doc", DocumentFormat.Json); db.Set().Add(doc); await db.SaveChangesAsync(); @@ -655,7 +662,7 @@ public async Task Attempts_lists_only_this_pairs_stamped_failures_pending_first( var db = scope.ServiceProvider.GetRequiredService(); var ctx = scope.Superuser(); - var doc = new Document(7012, "Attempts Doc"); + var doc = new Document(null, "Attempts Doc", DocumentFormat.Json); db.Set().Add(doc); await db.SaveChangesAsync(); @@ -725,7 +732,7 @@ public async Task Attempts_rejects_a_subscription_that_does_not_use_the_policy() var db = scope.ServiceProvider.GetRequiredService(); var ctx = scope.Superuser(); - var doc = new Document(7013, "Attempts Scope Doc"); + var doc = new Document(null, "Attempts Scope Doc", DocumentFormat.Json); db.Set().Add(doc); await db.SaveChangesAsync(); @@ -828,7 +835,7 @@ public async Task Exhausting_a_budget_claims_the_alert_exactly_once() await using var scope = _fixture.CreateScope(); var db = scope.ServiceProvider.GetRequiredService(); - var doc = new Document(7101, "Alert Claim Doc"); + var doc = new Document(null, "Alert Claim Doc", DocumentFormat.Json); db.Set().Add(doc); await db.SaveChangesAsync(); @@ -868,7 +875,7 @@ public async Task Concurrent_refusals_claim_the_alert_only_once() await using var setupScope = _fixture.CreateScope(); var setupDb = setupScope.ServiceProvider.GetRequiredService(); - var doc = new Document(7102, "Alert Race Doc"); + var doc = new Document(null, "Alert Race Doc", DocumentFormat.Json); setupDb.Set().Add(doc); await setupDb.SaveChangesAsync(); @@ -906,7 +913,7 @@ public async Task Resetting_usage_re_arms_the_exhaustion_alert() var db = scope.ServiceProvider.GetRequiredService(); var ctx = scope.Superuser(); - var doc = new Document(7103, "Alert Rearm Doc"); + var doc = new Document(null, "Alert Rearm Doc", DocumentFormat.Json); db.Set().Add(doc); await db.SaveChangesAsync(); @@ -970,7 +977,7 @@ public async Task An_inline_policy_budget_can_be_reported_and_reset_by_subscript var db = scope.ServiceProvider.GetRequiredService(); var ctx = scope.Superuser(); - var doc = new Document(7015, "Inline Policy Doc"); + var doc = new Document(null, "Inline Policy Doc", DocumentFormat.Json); db.Set().Add(doc); await db.SaveChangesAsync(); @@ -1112,7 +1119,7 @@ public async Task Overriding_an_inherited_alert_keeps_the_password_it_was_only_s var db = scope.ServiceProvider.GetRequiredService(); var ctx = scope.Superuser(); - var doc = new Document(7014, "Copied Secret Doc"); + var doc = new Document(null, "Copied Secret Doc", DocumentFormat.Json); db.Set().Add(doc); await db.SaveChangesAsync(); @@ -1209,7 +1216,7 @@ public async Task A_retry_started_by_hand_is_left_alone_by_the_policy() const string failure = "manual retry budget probe failed"; - var doc = new Document(7031, "Manual Retry Doc"); + var doc = new Document(null, "Manual Retry Doc", DocumentFormat.Json); db.Set().Add(doc); await db.SaveChangesAsync(); @@ -1322,8 +1329,9 @@ public async Task A_success_gives_the_group_its_spent_budget_back() var db = scope.ServiceProvider.GetRequiredService(); var xs = scope.ServiceProvider.GetRequiredService(); - var doc = new Document(7032, "Recovery Doc"); + var doc = new Document(null, "Recovery Doc", DocumentFormat.Json); db.Set().Add(doc); + await db.SaveChangesAsync(); var sub = new Subscription("Recovery Sub", doc.Id); db.Set().Add(sub); await db.SaveChangesAsync(); @@ -1401,8 +1409,9 @@ public async Task A_partly_spent_budget_is_left_alone_by_a_success() var db = scope.ServiceProvider.GetRequiredService(); var xs = scope.ServiceProvider.GetRequiredService(); - var doc = new Document(7033, "Partly Spent Doc"); + var doc = new Document(null, "Partly Spent Doc", DocumentFormat.Json); db.Set().Add(doc); + await db.SaveChangesAsync(); var sub = new Subscription("Partly Spent Sub", doc.Id); db.Set().Add(sub); await db.SaveChangesAsync(); @@ -1460,8 +1469,9 @@ public async Task A_slot_charged_after_the_success_began_is_not_handed_back() await using var scope = _fixture.CreateScope(); var db = scope.ServiceProvider.GetRequiredService(); - var doc = new Document(7034, "Watermark Doc"); + var doc = new Document(null, "Watermark Doc", DocumentFormat.Json); db.Set().Add(doc); + await db.SaveChangesAsync(); var sub = new Subscription("Watermark Sub", doc.Id); db.Set().Add(sub); await db.SaveChangesAsync(); diff --git a/SW.Bitween.IntegrationTests/Tests/SettingsTests.cs b/SW.Bitween.IntegrationTests/Tests/SettingsTests.cs new file mode 100644 index 00000000..ea0e0601 --- /dev/null +++ b/SW.Bitween.IntegrationTests/Tests/SettingsTests.cs @@ -0,0 +1,175 @@ +using System.Collections.Generic; +using System.Threading.Tasks; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.DependencyInjection; +using SW.Bitween.Domain; +using SW.Bitween.IntegrationTests.Fixtures; +using SW.Bitween.Model; +using SW.Bitween.Services; +using SW.PrimitiveTypes; +using Xunit; + +namespace SW.Bitween.IntegrationTests.Tests; + +/// +/// Instance settings — the values an administrator can change without a redeploy. +/// +/// +/// Two things make this worth covering. Secrets are encrypted before they are written, so a +/// database dump never carries a license key in the clear — and an encryption bug is invisible +/// from the UI, which shows the value correctly either way. And only some settings are editable at +/// all: the page also lists environment-owned ones, and accepting a write to those would report +/// success for a change that can never take effect. +/// +[Collection("Bitween")] +public class SettingsTests : IAsyncLifetime +{ + private const string SecretKey = "Bitween.RebexLicenseKey"; + private const string EditableKey = "Bitween.JwtExpiryMinutes"; + private const string EnvironmentOwnedKey = "Bitween.DocumentPrefix"; + + private readonly BitweenFixture _fixture; + private readonly Dictionary _originals = new(); + + public SettingsTests(BitweenFixture fixture) + { + _fixture = fixture; + } + + /// + /// Applying a setting mutates a process-wide options singleton that every test in this + /// collection shares, so anything written here has to be put back. Nothing depends on these + /// two today — but the Rebex key decides which adapters are offered at all, and a future test + /// of that would pass or fail purely on whether this class happened to run first. + /// + public async Task InitializeAsync() + { + foreach (var key in new[] { SecretKey, EditableKey }) + _originals[key] = await LiveValue(key); + } + + public async Task DisposeAsync() + { + foreach (var (key, value) in _originals) + await Store(key, value); + } + + private async Task Store(string key, string value) + { + await using var scope = _fixture.CreateScope(); + scope.Superuser(); + var handler = ActivatorUtilities.CreateInstance(scope.ServiceProvider); + await handler.Handle(key, new SettingUpdate { Value = value }); + } + + private async Task RawStored(string key) + { + await using var scope = _fixture.CreateScope(); + var db = scope.ServiceProvider.GetRequiredService(); + var row = await db.Set().AsNoTracking().SingleOrDefaultAsync(s => s.Id == key); + return row?.Value; + } + + private async Task LiveValue(string key) + { + await using var scope = _fixture.CreateScope(); + var settings = scope.ServiceProvider.GetRequiredService(); + return settings.LiveValue(SettingsCatalog.Find(key)); + } + + [Fact] + public async Task A_secret_is_never_written_in_the_clear() + { + const string licenseKey = "REBEX-1234-SECRET-VALUE"; + await Store(SecretKey, licenseKey); + + var raw = await RawStored(SecretKey); + + // The whole point: a database dump or backup must not carry this readable. + Assert.DoesNotContain(licenseKey, raw); + Assert.StartsWith("enc.v1:", raw); + } + + [Fact] + public async Task A_secret_comes_back_as_it_went_in() + { + const string licenseKey = "REBEX-ROUND-TRIP-9876"; + await Store(SecretKey, licenseKey); + + // Encryption that cannot be reversed is indistinguishable from a corrupted value until + // the adapter it licenses refuses to start. + Assert.Equal(licenseKey, await LiveValue(SecretKey)); + } + + [Fact] + public async Task The_same_secret_stored_twice_does_not_produce_the_same_ciphertext() + { + await Store(SecretKey, "REBEX-REPEATED-VALUE"); + var first = await RawStored(SecretKey); + await Store(SecretKey, "REBEX-REPEATED-VALUE"); + var second = await RawStored(SecretKey); + + // A fresh salt and nonce each time, so anyone reading the table cannot tell that two + // instances share a license key, or spot when one goes back to a previous value. + Assert.NotEqual(first, second); + Assert.Equal("REBEX-REPEATED-VALUE", await LiveValue(SecretKey)); + } + + [Fact] + public async Task An_ordinary_setting_takes_effect_immediately_and_is_stored_as_written() + { + await Store(EditableKey, "45"); + + // Applied to the live options singleton as well as stored, so the very next request + // already sees it — there is no restart-required concept in this product. + Assert.Equal("45", await LiveValue(EditableKey)); + Assert.Equal("45", await RawStored(EditableKey)); + } + + [Fact] + public async Task An_environment_owned_setting_is_refused_rather_than_quietly_ignored() + { + var ex = await Assert.ThrowsAsync(() => + Store(EnvironmentOwnedKey, "some-other-prefix")); + + // The page lists these so an administrator can see them, which makes it easy to try + // editing one. Accepting the write would report success for a change that can never + // take effect — and for this key, one that would strand everything already stored. + Assert.StartsWith("SETTING_NOT_EDITABLE", ex.Message); + Assert.Null(await RawStored(EnvironmentOwnedKey)); + } + + [Fact] + public async Task A_key_that_is_not_a_setting_is_refused() + { + var ex = await Assert.ThrowsAsync(() => + Store("Bitween.NotARealSetting", "value")); + + // The catalog is the whole list of what exists. Storing an unknown key would leave a row + // nothing ever reads, looking like a setting that simply does not work. + Assert.StartsWith("SETTING_NOT_FOUND", ex.Message); + } + + [Fact] + public async Task A_value_the_setting_cannot_hold_is_refused() + { + var ex = await Assert.ThrowsAsync(() => Store(EditableKey, "not-a-number")); + + // Stored unchecked, this fails much later — when something reads the session length and + // cannot parse it, far from the screen where it was typed. + Assert.StartsWith("SETTING_INVALID_VALUE", ex.Message); + } + + [Fact] + public async Task Clearing_an_optional_setting_is_a_real_change_not_a_no_op() + { + await Store(SecretKey, "REBEX-TO-BE-CLEARED"); + Assert.NotEqual(string.Empty, await LiveValue(SecretKey)); + + await Store(SecretKey, string.Empty); + + // Empty is how you remove a license key or an optional link. Treating it as "nothing to + // do" would make a setting impossible to unset once set. + Assert.Equal(string.Empty, await LiveValue(SecretKey)); + } +} diff --git a/SW.Bitween.IntegrationTests/Tests/SubscriptionLifecycleTests.cs b/SW.Bitween.IntegrationTests/Tests/SubscriptionLifecycleTests.cs new file mode 100644 index 00000000..c6c8afe8 --- /dev/null +++ b/SW.Bitween.IntegrationTests/Tests/SubscriptionLifecycleTests.cs @@ -0,0 +1,246 @@ +using System.Collections.Generic; +using System.Threading; +using System.Linq; +using System.Threading.Tasks; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.DependencyInjection; +using SW.Bitween.Domain; +using SW.Bitween.Domain.Gateway; +using SW.Bitween.IntegrationTests.Fixtures; +using SW.Bitween.Model; +using SW.PrimitiveTypes; +using Xunit; + +namespace SW.Bitween.IntegrationTests.Tests; + +/// +/// Creating, changing and removing an integration — the entity the whole product is arranged +/// around, and the one the redesigned UI touches on nearly every screen. +/// +/// +/// The delete path gets most of the attention here. All four references are RESTRICT in the +/// database, so a delete was always refused; what the handler adds is saying what is holding +/// it instead of letting a foreign key violation surface as a 500. That message is the whole +/// feature, so these tests assert on its content rather than just on the refusal — a check that +/// only asserted "it threw" would still pass if the message went back to being useless. +/// +[Collection("Bitween")] +public class SubscriptionLifecycleTests +{ + private readonly BitweenFixture _fixture; + + public SubscriptionLifecycleTests(BitweenFixture fixture) + { + _fixture = fixture; + } + + private static int _seq; + private static string Unique(string prefix) => $"{prefix}-{Interlocked.Increment(ref _seq)}"; + + /// An information type and partner to hang integrations off. + private async Task<(int documentId, int partnerId)> Groundwork() + { + await using var scope = _fixture.CreateScope(); + var db = scope.ServiceProvider.GetRequiredService(); + + var document = new Document(null, Unique("Lifecycle doc"), DocumentFormat.Json); + db.Set().Add(document); + var partner = new Partner(Unique("Lifecycle partner")); + db.Set().Add(partner); + await db.SaveChangesAsync(); + + return (document.Id, partner.Id); + } + + private async Task CreateSubscription(string name, int documentId, int partnerId, + SubscriptionType type = SubscriptionType.ApiCall) + { + await using var scope = _fixture.CreateScope(); + scope.Superuser(); + var handler = ActivatorUtilities.CreateInstance(scope.ServiceProvider); + + var created = await handler.Handle(new SubscriptionCreate + { + Name = name, + DocumentId = documentId, + PartnerId = partnerId, + Type = type, + }); + + return (int)created; + } + + private async Task Delete(int subscriptionId) + { + await using var scope = _fixture.CreateScope(); + scope.Superuser(); + var handler = ActivatorUtilities.CreateInstance(scope.ServiceProvider); + await handler.Handle(subscriptionId); + } + + [Fact] + public async Task An_integration_can_be_created_changed_and_removed() + { + var (documentId, partnerId) = await Groundwork(); + var id = await CreateSubscription(Unique("Round trip"), documentId, partnerId); + + await using (var scope = _fixture.CreateScope()) + { + var db = scope.ServiceProvider.GetRequiredService(); + var stored = await db.Set().SingleAsync(s => s.Id == id); + + // Every constructor starts an integration inactive, so nothing begins running the + // moment it is saved half-configured. + Assert.True(stored.Inactive); + } + + await Delete(id); + + await using (var scope = _fixture.CreateScope()) + { + var db = scope.ServiceProvider.GetRequiredService(); + Assert.False(await db.Set().AnyAsync(s => s.Id == id)); + } + } + + [Fact] + public async Task Deleting_names_the_bus_gateway_route_still_pointing_at_it() + { + var (documentId, partnerId) = await Groundwork(); + var id = await CreateSubscription(Unique("Routed"), documentId, partnerId); + + await using (var scope = _fixture.CreateScope()) + { + var db = scope.ServiceProvider.GetRequiredService(); + var gateway = new BusGateway { Name = "Orders bus", DocumentId = documentId }; + db.Set().Add(gateway); + await db.SaveChangesAsync(); + + db.Set().Add(new BusGatewayRoute { BusGatewayId = gateway.Id, SubscriptionId = id }); + await db.SaveChangesAsync(); + } + + var ex = await Assert.ThrowsAsync(() => Delete(id)); + + // The operator has to be able to go straight to the thing holding it — a bare refusal + // sends them hunting through every gateway by hand. + Assert.Contains("Orders bus", ex.Message); + Assert.Contains("route", ex.Message); + } + + [Fact] + public async Task Deleting_names_the_aggregation_still_pointing_at_it() + { + var (documentId, partnerId) = await Groundwork(); + var source = await CreateSubscription(Unique("Aggregated source"), documentId, partnerId); + + await using (var scope = _fixture.CreateScope()) + { + scope.Superuser(); + var handler = ActivatorUtilities.CreateInstance(scope.ServiceProvider); + await handler.Handle(new SubscriptionCreate + { + Name = "Nightly rollup", + DocumentId = documentId, + PartnerId = partnerId, + AggregationForId = source, + Type = SubscriptionType.Aggregation, + }); + } + + var ex = await Assert.ThrowsAsync(() => Delete(source)); + + Assert.Contains("Nightly rollup", ex.Message); + Assert.Contains("aggregation", ex.Message); + } + + [Fact] + public async Task Deleting_lists_every_holder_at_once_rather_than_one_at_a_time() + { + var (documentId, partnerId) = await Groundwork(); + var id = await CreateSubscription(Unique("Popular"), documentId, partnerId); + + await using (var scope = _fixture.CreateScope()) + { + var db = scope.ServiceProvider.GetRequiredService(); + + var gateway = new BusGateway { Name = "First bus", DocumentId = documentId }; + db.Set().Add(gateway); + await db.SaveChangesAsync(); + db.Set().Add(new BusGatewayRoute { BusGatewayId = gateway.Id, SubscriptionId = id }); + + var second = new BusGateway { Name = "Second bus", DocumentId = documentId }; + db.Set().Add(second); + await db.SaveChangesAsync(); + db.Set().Add(new BusGatewayRoute { BusGatewayId = second.Id, SubscriptionId = id }); + + await db.SaveChangesAsync(); + } + + var ex = await Assert.ThrowsAsync(() => Delete(id)); + + // Reporting one holder per attempt turns clearing an integration into a guessing game. + Assert.Contains("First bus", ex.Message); + Assert.Contains("Second bus", ex.Message); + } + + [Fact] + public async Task An_integration_nothing_points_at_deletes_cleanly() + { + var (documentId, partnerId) = await Groundwork(); + var id = await CreateSubscription(Unique("Unreferenced"), documentId, partnerId); + + // The guard has to stay narrow: if it over-reaches, nothing can ever be deleted and the + // only way out is the database. + await Delete(id); + + await using var scope = _fixture.CreateScope(); + var db = scope.ServiceProvider.GetRequiredService(); + Assert.False(await db.Set().AnyAsync(s => s.Id == id)); + } + + [Fact] + public async Task Exchange_history_does_not_keep_an_integration_alive() + { + var (documentId, partnerId) = await Groundwork(); + var id = await CreateSubscription(Unique("Has history"), documentId, partnerId); + + await using (var scope = _fixture.CreateScope()) + { + var db = scope.ServiceProvider.GetRequiredService(); + var subscription = await db.Set().SingleAsync(s => s.Id == id); + db.Set().Add(new Xchange(subscription, new XchangeFile("{\"done\":true}"))); + await db.SaveChangesAsync(); + } + + // Deliberate: an exchange's reference is nullable, and past traffic is not a reason to keep + // configuration around forever. + await Delete(id); + + await using var finalScope = _fixture.CreateScope(); + var finalDb = finalScope.ServiceProvider.GetRequiredService(); + Assert.False(await finalDb.Set().AnyAsync(s => s.Id == id)); + } + + [Fact] + public async Task A_viewer_cannot_create_or_delete_an_integration() + { + var (documentId, partnerId) = await Groundwork(); + var id = await CreateSubscription(Unique("Guarded"), documentId, partnerId); + + await using var scope = _fixture.CreateScope(); + await scope.AsNewViewer(Unique("sub-viewer")); + + var create = ActivatorUtilities.CreateInstance(scope.ServiceProvider); + await Assert.ThrowsAsync(() => create.Handle(new SubscriptionCreate + { + Name = "Should not exist", + DocumentId = documentId, + PartnerId = partnerId, + Type = SubscriptionType.ApiCall, + })); + + var delete = ActivatorUtilities.CreateInstance(scope.ServiceProvider); + await Assert.ThrowsAsync(() => delete.Handle(id)); + } +} diff --git a/SW.Bitween.IntegrationTests/Tests/SubscriptionSecretTests.cs b/SW.Bitween.IntegrationTests/Tests/SubscriptionSecretTests.cs new file mode 100644 index 00000000..5fbba8f5 --- /dev/null +++ b/SW.Bitween.IntegrationTests/Tests/SubscriptionSecretTests.cs @@ -0,0 +1,165 @@ +using System.Collections.Generic; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.DependencyInjection; +using SW.Bitween.Domain; +using SW.Bitween.IntegrationTests.Fixtures; +using SW.Bitween.Model; +using SW.PrimitiveTypes; +using Xunit; + +namespace SW.Bitween.IntegrationTests.Tests; + +/// +/// What happens to a saved password when the person editing an integration never sees it. +/// +/// +/// Secrets are masked on the way out as __private__ and restored on the way back in. That +/// leaves an obvious way to destroy one by accident: the browser sends back the dots it was shown, +/// and a handler that took them literally would overwrite a working password with the placeholder +/// — breaking the integration at the next run, with nothing in the audit trail that looks like a +/// password change. These tests hold that behaviour in place from both directions: the sentinel +/// never lands in storage, and a real new value still does. +/// +[Collection("Bitween")] +public class SubscriptionSecretTests +{ + private const string Sentinel = "__private__"; + private const string RealPassword = "s3cr3t-smtp-password"; + + private readonly BitweenFixture _fixture; + + public SubscriptionSecretTests(BitweenFixture fixture) + { + _fixture = fixture; + } + + private static int _seq; + private static string Unique(string prefix) => $"{prefix}-{Interlocked.Increment(ref _seq)}"; + + private async Task<(int subscriptionId, int documentId, int partnerId)> AnIntegrationWithASecret() + { + await using var scope = _fixture.CreateScope(); + var db = scope.ServiceProvider.GetRequiredService(); + + var document = new Document(null, Unique("Secret doc"), DocumentFormat.Json); + db.Set().Add(document); + var partner = new Partner(Unique("Secret partner")); + db.Set().Add(partner); + await db.SaveChangesAsync(); + + scope.Superuser(); + var create = ActivatorUtilities.CreateInstance(scope.ServiceProvider); + var id = (int)await create.Handle(new SubscriptionCreate + { + Name = Unique("Sends mail"), + DocumentId = document.Id, + PartnerId = partner.Id, + Type = SubscriptionType.ApiCall, + HandlerProperties = + [ + new KeyAndValue { Key = "Host", Value = "smtp.example.com" }, + new KeyAndValue { Key = "Password", Value = RealPassword }, + ], + }); + + return (id, document.Id, partner.Id); + } + + private async Task Update(int id, int documentId, int partnerId, params KeyAndValue[] handlerProperties) + { + await using var scope = _fixture.CreateScope(); + scope.Superuser(); + var update = ActivatorUtilities.CreateInstance(scope.ServiceProvider); + await update.Handle(id, new SubscriptionUpdate + { + Name = Unique("Sends mail"), + DocumentId = documentId, + PartnerId = partnerId, + HandlerProperties = handlerProperties, + }); + } + + private async Task> StoredProperties(int id) + { + await using var scope = _fixture.CreateScope(); + var db = scope.ServiceProvider.GetRequiredService(); + var stored = await db.Set().AsNoTracking().SingleAsync(s => s.Id == id); + return stored.HandlerProperties; + } + + [Fact] + public async Task Saving_the_mask_back_keeps_the_stored_secret() + { + var (id, documentId, partnerId) = await AnIntegrationWithASecret(); + + // Exactly what the browser posts when someone edits the host and leaves the password alone. + await Update(id, documentId, partnerId, + new KeyAndValue { Key = "Host", Value = "smtp.newhost.com" }, + new KeyAndValue { Key = "Password", Value = Sentinel }); + + var properties = await StoredProperties(id); + + Assert.Equal(RealPassword, properties["Password"]); + Assert.Equal("smtp.newhost.com", properties["Host"]); + } + + [Fact] + public async Task The_mask_is_never_what_gets_stored() + { + var (id, documentId, partnerId) = await AnIntegrationWithASecret(); + + await Update(id, documentId, partnerId, + new KeyAndValue { Key = "Host", Value = "smtp.example.com" }, + new KeyAndValue { Key = "Password", Value = Sentinel }); + + // Stated separately from the assertion above because this is the failure that would be + // silent: the integration keeps saving fine and only breaks when it next tries to connect. + Assert.DoesNotContain(Sentinel, (await StoredProperties(id)).Values); + } + + [Fact] + public async Task A_real_new_secret_replaces_the_old_one() + { + var (id, documentId, partnerId) = await AnIntegrationWithASecret(); + + await Update(id, documentId, partnerId, + new KeyAndValue { Key = "Host", Value = "smtp.example.com" }, + new KeyAndValue { Key = "Password", Value = "a-different-password" }); + + // The protection must not become a trap where the password can never be changed. + Assert.Equal("a-different-password", (await StoredProperties(id))["Password"]); + } + + [Fact] + public async Task Dropping_a_property_removes_it_rather_than_restoring_it() + { + var (id, documentId, partnerId) = await AnIntegrationWithASecret(); + + await Update(id, documentId, partnerId, + new KeyAndValue { Key = "Host", Value = "smtp.example.com" }); + + // Only a mask restores the stored value. A property the caller omits entirely is gone — + // otherwise a setting could never be cleared once set. + Assert.False((await StoredProperties(id)).ContainsKey("Password")); + } + + [Fact] + public async Task A_mask_for_a_property_that_was_never_stored_is_dropped() + { + var (id, documentId, partnerId) = await AnIntegrationWithASecret(); + + await Update(id, documentId, partnerId, + new KeyAndValue { Key = "Host", Value = "smtp.example.com" }, + new KeyAndValue { Key = "Password", Value = Sentinel }, + new KeyAndValue { Key = "ApiKey", Value = Sentinel }); + + // There is nothing to restore, so the sentinel must not be written through as if it were + // the value — that is how the literal string "__private__" ends up being sent as an API key. + var properties = await StoredProperties(id); + Assert.False(properties.ContainsKey("ApiKey")); + Assert.Equal(RealPassword, properties["Password"]); + } +} diff --git a/SW.Bitween.MsSql/Migrations/20260826101529_FixDefaultCreatedOnTimezone.Designer.cs b/SW.Bitween.MsSql/Migrations/20260826101529_FixDefaultCreatedOnTimezone.Designer.cs new file mode 100644 index 00000000..b8f88f4f --- /dev/null +++ b/SW.Bitween.MsSql/Migrations/20260826101529_FixDefaultCreatedOnTimezone.Designer.cs @@ -0,0 +1,2138 @@ +// +using System; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Metadata; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using SW.Bitween.MsSql; + +#nullable disable + +namespace SW.Bitween.MsSql.Migrations +{ + [DbContext(typeof(BitweenDbContext))] + [Migration("20260826101529_FixDefaultCreatedOnTimezone")] + partial class FixDefaultCreatedOnTimezone + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "9.0.19") + .HasAnnotation("Relational:MaxIdentifierLength", 128); + + SqlServerModelBuilderExtensions.UseIdentityColumns(modelBuilder); + + modelBuilder.HasSequence("DocumentIds"); + + modelBuilder.Entity("SW.Bitween.Domain.Accounts.Account", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("CreatedBy") + .HasColumnType("nvarchar(max)"); + + b.Property("CreatedOn") + .HasColumnType("datetime2"); + + b.Property("Deleted") + .HasColumnType("bit"); + + b.Property("Disabled") + .HasColumnType("bit"); + + b.Property("DisplayName") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("Email") + .HasMaxLength(200) + .IsUnicode(false) + .HasColumnType("varchar(200)"); + + b.Property("EmailProvider") + .HasColumnType("tinyint"); + + b.Property("FailedLoginCount") + .HasColumnType("int"); + + b.Property("LockoutEnd") + .HasColumnType("datetime2"); + + b.Property("LoginMethods") + .HasColumnType("tinyint"); + + b.Property("ModifiedBy") + .HasColumnType("nvarchar(max)"); + + b.Property("ModifiedOn") + .HasColumnType("datetime2"); + + b.Property("Password") + .HasMaxLength(500) + .IsUnicode(false) + .HasColumnType("varchar(500)"); + + b.Property("Role") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("Email") + .IsUnique() + .HasFilter("[Email] IS NOT NULL"); + + b.ToTable("Accounts", (string)null); + + b.HasData( + new + { + Id = 9999, + CreatedOn = new DateTime(2022, 1, 1, 0, 0, 0, 0, DateTimeKind.Utc), + Deleted = false, + Disabled = false, + DisplayName = "Admin", + Email = "admin@Bitween.systems", + EmailProvider = (byte)0, + FailedLoginCount = 0, + LoginMethods = (byte)2, + Password = "$SWHASH$V1$10000$VQCi48eitH4Ml5juvBMOFZrMdQwBbhuIQVXe6RR7qJdDF2bJ", + Role = 0 + }); + }); + + modelBuilder.Entity("SW.Bitween.Domain.Accounts.AccountRoleLink", b => + { + b.Property("AccountId") + .HasColumnType("int"); + + b.Property("RoleId") + .HasColumnType("int"); + + b.HasKey("AccountId", "RoleId"); + + b.HasIndex("RoleId"); + + b.ToTable("AccountRoles", (string)null); + }); + + modelBuilder.Entity("SW.Bitween.Domain.Accounts.RefreshToken", b => + { + b.Property("Id") + .HasMaxLength(50) + .IsUnicode(false) + .HasColumnType("varchar(50)"); + + b.Property("AccountId") + .HasColumnType("int"); + + b.Property("CreatedOn") + .HasColumnType("datetime2"); + + b.Property("LoginMethod") + .HasColumnType("tinyint"); + + b.HasKey("Id"); + + b.HasIndex("AccountId"); + + b.ToTable("RefreshTokens", (string)null); + }); + + modelBuilder.Entity("SW.Bitween.Domain.Accounts.Role", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("CreatedBy") + .HasColumnType("nvarchar(max)"); + + b.Property("CreatedOn") + .HasColumnType("datetime2"); + + b.Property("Description") + .HasMaxLength(500) + .HasColumnType("nvarchar(500)"); + + b.Property("IsSystem") + .HasColumnType("bit"); + + b.Property("ModifiedBy") + .HasColumnType("nvarchar(max)"); + + b.Property("ModifiedOn") + .HasColumnType("datetime2"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.Property("Permissions") + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.HasIndex("Name") + .IsUnique(); + + b.ToTable("Roles", (string)null); + + b.HasData( + new + { + Id = 1, + CreatedOn = new DateTime(2022, 1, 1, 0, 0, 0, 0, DateTimeKind.Utc), + Description = "Full access to everything, including members, roles and settings.", + IsSystem = true, + Name = "Administrator", + Permissions = "[]" + }, + new + { + Id = 2, + CreatedOn = new DateTime(2022, 1, 1, 0, 0, 0, 0, DateTimeKind.Utc), + Description = "Runs and configures integrations. Can't manage members, roles or settings.", + IsSystem = true, + Name = "Member", + Permissions = "[]" + }, + new + { + Id = 3, + CreatedOn = new DateTime(2022, 1, 1, 0, 0, 0, 0, DateTimeKind.Utc), + Description = "Read-only access to integrations, exchanges and configuration.", + IsSystem = true, + Name = "Viewer", + Permissions = "[]" + }); + }); + + modelBuilder.Entity("SW.Bitween.Domain.DelayedRetry", b => + { + b.Property("Id") + .HasMaxLength(50) + .IsUnicode(false) + .HasColumnType("varchar(50)"); + + b.Property("On") + .HasColumnType("datetime2"); + + b.HasKey("Id"); + + b.HasIndex("On"); + + b.ToTable("DelayedRetries", (string)null); + }); + + modelBuilder.Entity("SW.Bitween.Domain.Document", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int") + .HasDefaultValueSql("NEXT VALUE FOR [DocumentIds]"); + + SqlServerPropertyBuilderExtensions.UseSequence(b.Property("Id"), "DocumentIds"); + + b.Property("BusEnabled") + .HasColumnType("bit"); + + b.Property("BusMessageTypeName") + .HasMaxLength(500) + .IsUnicode(false) + .HasColumnType("varchar(500)"); + + b.Property("Code") + .HasMaxLength(50) + .IsUnicode(false) + .HasColumnType("varchar(50)"); + + b.Property("DisregardsUnfilteredMessages") + .HasColumnType("bit"); + + b.Property("DocumentFormat") + .HasColumnType("int"); + + b.Property("DuplicateInterval") + .HasColumnType("int"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .IsUnicode(false) + .HasColumnType("varchar(100)"); + + b.Property("PromotedProperties") + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.HasIndex("BusMessageTypeName") + .IsUnique() + .HasFilter("[BusMessageTypeName] IS NOT NULL"); + + b.HasIndex("Code") + .IsUnique() + .HasFilter("[Code] IS NOT NULL"); + + b.HasIndex("Name") + .IsUnique(); + + b.ToTable("Documents", (string)null); + + b.HasData( + new + { + Id = 10001, + BusEnabled = false, + DocumentFormat = 0, + DuplicateInterval = 0, + Name = "Aggregation Document", + PromotedProperties = "{}" + }); + }); + + modelBuilder.Entity("SW.Bitween.Domain.DocumentTrail", b => + { + b.Property("Id") + .HasMaxLength(50) + .HasColumnType("nvarchar(50)"); + + b.Property("Code") + .HasColumnType("int"); + + b.Property("CreatedBy") + .HasColumnType("nvarchar(max)"); + + b.Property("CreatedOn") + .HasColumnType("datetime2"); + + b.Property("DocumentId") + .HasColumnType("int"); + + b.Property("StateAfter") + .HasColumnType("nvarchar(max)"); + + b.Property("StateBefore") + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.HasIndex("CreatedOn"); + + b.HasIndex("DocumentId"); + + b.ToTable("DocumentTrail"); + }); + + modelBuilder.Entity("SW.Bitween.Domain.Gateway.ApiGateway", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("CreatedBy") + .HasColumnType("nvarchar(max)"); + + b.Property("CreatedOn") + .HasColumnType("datetime2"); + + b.Property("Inactive") + .HasColumnType("bit"); + + b.Property("ModifiedBy") + .HasColumnType("nvarchar(max)"); + + b.Property("ModifiedOn") + .HasColumnType("datetime2"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("UrlName") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.HasKey("Id"); + + b.HasIndex("UrlName") + .IsUnique(); + + b.ToTable("ApiGateways", (string)null); + }); + + modelBuilder.Entity("SW.Bitween.Domain.Gateway.ApiGatewayPartner", b => + { + b.Property("ApiGatewayId") + .HasColumnType("int"); + + b.Property("PartnerId") + .HasColumnType("int"); + + b.Property("SubscriptionId") + .HasColumnType("int"); + + b.Property("CreatedBy") + .HasColumnType("nvarchar(max)"); + + b.Property("CreatedOn") + .HasColumnType("datetime2"); + + b.Property("ModifiedBy") + .HasColumnType("nvarchar(max)"); + + b.Property("ModifiedOn") + .HasColumnType("datetime2"); + + b.HasKey("ApiGatewayId", "PartnerId", "SubscriptionId"); + + b.HasIndex("PartnerId"); + + b.HasIndex("SubscriptionId"); + + b.ToTable("ApiGatewayPartners", (string)null); + }); + + modelBuilder.Entity("SW.Bitween.Domain.Gateway.BusGateway", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("CreatedBy") + .HasColumnType("nvarchar(max)"); + + b.Property("CreatedOn") + .HasColumnType("datetime2"); + + b.Property("DocumentId") + .HasColumnType("int"); + + b.Property("Inactive") + .HasColumnType("bit"); + + b.Property("ModifiedBy") + .HasColumnType("nvarchar(max)"); + + b.Property("ModifiedOn") + .HasColumnType("datetime2"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.HasKey("Id"); + + b.HasIndex("DocumentId"); + + b.ToTable("BusGateways", (string)null); + }); + + modelBuilder.Entity("SW.Bitween.Domain.Gateway.BusGatewayRoute", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("BusGatewayId") + .HasColumnType("int"); + + b.Property("CreatedBy") + .HasColumnType("nvarchar(max)"); + + b.Property("CreatedOn") + .HasColumnType("datetime2"); + + b.Property("MatchExpression") + .HasColumnType("nvarchar(max)"); + + b.Property("ModifiedBy") + .HasColumnType("nvarchar(max)"); + + b.Property("ModifiedOn") + .HasColumnType("datetime2"); + + b.Property("PartnerId") + .HasColumnType("int"); + + b.Property("SubscriptionId") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("BusGatewayId"); + + b.HasIndex("PartnerId"); + + b.HasIndex("SubscriptionId"); + + b.ToTable("BusGatewayRoutes", (string)null); + }); + + modelBuilder.Entity("SW.Bitween.Domain.GlobalAdapterValuesSet", b => + { + b.Property("Id") + .HasMaxLength(200) + .IsUnicode(false) + .HasColumnType("varchar(200)"); + + b.Property("Name") + .HasColumnType("nvarchar(max)"); + + b.Property("Values") + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.ToTable("GlobalAdapterValuesSets", (string)null); + }); + + modelBuilder.Entity("SW.Bitween.Domain.Notifier", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("HandlerId") + .HasMaxLength(200) + .IsUnicode(false) + .HasColumnType("varchar(200)"); + + b.Property("HandlerProperties") + .HasColumnType("nvarchar(max)"); + + b.Property("Inactive") + .HasColumnType("bit"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.Property("RunOnBadResult") + .HasColumnType("bit"); + + b.Property("RunOnFailedResult") + .HasColumnType("bit"); + + b.Property("RunOnSubscriptions") + .HasColumnType("nvarchar(max)"); + + b.Property("RunOnSuccessfulResult") + .HasColumnType("bit"); + + b.HasKey("Id"); + + b.ToTable("Notifiers", (string)null); + }); + + modelBuilder.Entity("SW.Bitween.Domain.OnHoldXchange", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("BadData") + .HasColumnType("bit"); + + b.Property("Data") + .HasColumnType("nvarchar(max)"); + + b.Property("FileName") + .HasColumnType("nvarchar(max)"); + + b.Property("References") + .HasMaxLength(1024) + .HasColumnType("nvarchar(1024)"); + + b.Property("SubscriptionId") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("SubscriptionId"); + + b.ToTable("OnHoldXchanges", (string)null); + }); + + modelBuilder.Entity("SW.Bitween.Domain.Partner", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("AdapterProperties") + .HasColumnType("nvarchar(max)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .IsUnicode(false) + .HasColumnType("varchar(200)"); + + b.HasKey("Id"); + + b.ToTable("Partners", (string)null); + + b.HasData( + new + { + Id = 1, + Name = "SYSTEM" + }); + }); + + modelBuilder.Entity("SW.Bitween.Domain.ReceiveAttempt", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("ErrorMessage") + .HasMaxLength(4000) + .HasColumnType("nvarchar(4000)"); + + b.Property("ExchangeIds") + .HasColumnType("nvarchar(max)"); + + b.Property("FinishedOn") + .HasColumnType("datetime2"); + + b.Property("Outcome") + .HasColumnType("int"); + + b.Property("StartedOn") + .HasColumnType("datetime2"); + + b.Property("SubscriptionId") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("SubscriptionId", "StartedOn"); + + b.ToTable("ReceiveAttempts", (string)null); + }); + + modelBuilder.Entity("SW.Bitween.Domain.RetryAlertOverride", b => + { + b.Property("SubscriptionId") + .HasColumnType("int"); + + b.Property("GroupId") + .HasColumnType("uniqueidentifier"); + + b.Property("AlertHandlerId") + .HasMaxLength(200) + .IsUnicode(false) + .HasColumnType("varchar(200)"); + + b.Property("AlertHandlerProperties") + .HasColumnType("nvarchar(max)"); + + b.Property("AlertMode") + .HasColumnType("tinyint"); + + b.HasKey("SubscriptionId", "GroupId"); + + b.ToTable("RetryAlertOverrides", (string)null); + }); + + modelBuilder.Entity("SW.Bitween.Domain.RetryGroupUsage", b => + { + b.Property("SubscriptionId") + .HasColumnType("int"); + + b.Property("GroupId") + .HasColumnType("uniqueidentifier"); + + b.Property("AttemptsUsed") + .HasColumnType("int"); + + b.Property("ExhaustedNotifiedOn") + .HasColumnType("datetime2"); + + b.Property("LastAttemptOn") + .HasColumnType("datetime2"); + + b.HasKey("SubscriptionId", "GroupId"); + + b.ToTable("RetryGroupUsages", (string)null); + }); + + modelBuilder.Entity("SW.Bitween.Domain.RetryPolicy", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("AlertHandlerId") + .HasMaxLength(200) + .IsUnicode(false) + .HasColumnType("varchar(200)"); + + b.Property("AlertHandlerProperties") + .HasColumnType("nvarchar(max)"); + + b.Property("CreatedBy") + .HasColumnType("nvarchar(max)"); + + b.Property("CreatedOn") + .HasColumnType("datetime2"); + + b.Property("Groups") + .HasColumnType("nvarchar(max)"); + + b.Property("ModifiedBy") + .HasColumnType("nvarchar(max)"); + + b.Property("ModifiedOn") + .HasColumnType("datetime2"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.HasKey("Id"); + + b.ToTable("RetryPolicies", (string)null); + }); + + modelBuilder.Entity("SW.Bitween.Domain.Setting", b => + { + b.Property("Id") + .HasMaxLength(200) + .IsUnicode(false) + .HasColumnType("varchar(200)"); + + b.Property("CreatedBy") + .HasColumnType("nvarchar(max)"); + + b.Property("CreatedOn") + .HasColumnType("datetime2"); + + b.Property("ModifiedBy") + .HasColumnType("nvarchar(max)"); + + b.Property("ModifiedOn") + .HasColumnType("datetime2"); + + b.Property("Value") + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.ToTable("Settings", (string)null); + }); + + modelBuilder.Entity("SW.Bitween.Domain.Subscription", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("AggregateOn") + .HasColumnType("datetime2"); + + b.Property("AggregationForId") + .HasColumnType("int"); + + b.Property("AggregationTarget") + .HasColumnType("tinyint"); + + b.Property("CategoryId") + .HasColumnType("int"); + + b.Property("ConsecutiveFailures") + .HasColumnType("int"); + + b.Property("CustomRetryPolicy") + .HasColumnType("nvarchar(max)"); + + b.Property("DocumentFilter") + .HasColumnType("nvarchar(max)"); + + b.Property("DocumentId") + .HasColumnType("int"); + + b.Property("HandlerId") + .HasMaxLength(200) + .IsUnicode(false) + .HasColumnType("varchar(200)"); + + b.Property("HandlerProperties") + .HasColumnType("nvarchar(max)"); + + b.Property("Inactive") + .HasColumnType("bit"); + + b.Property("IsRunning") + .HasColumnType("bit"); + + b.Property("LastException") + .HasColumnType("nvarchar(max)"); + + b.Property("MapperId") + .HasMaxLength(200) + .IsUnicode(false) + .HasColumnType("varchar(200)"); + + b.Property("MapperProperties") + .HasColumnType("nvarchar(max)"); + + b.Property("MatchExpression") + .HasColumnType("nvarchar(max)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.Property("PartnerId") + .HasColumnType("int"); + + b.Property("PausedOn") + .HasColumnType("datetime2"); + + b.Property("ReceiveOn") + .HasColumnType("datetime2"); + + b.Property("ReceiverId") + .HasMaxLength(200) + .IsUnicode(false) + .HasColumnType("varchar(200)"); + + b.Property("ReceiverProperties") + .HasColumnType("nvarchar(max)"); + + b.Property("ResponseMessageTypeName") + .HasMaxLength(500) + .IsUnicode(false) + .HasColumnType("varchar(500)"); + + b.Property("ResponseSubscriptionId") + .HasColumnType("int"); + + b.Property("RetryPolicyId") + .HasColumnType("int"); + + b.Property("Temporary") + .HasColumnType("bit"); + + b.Property("Type") + .HasColumnType("tinyint"); + + b.Property("ValidatorId") + .HasMaxLength(200) + .IsUnicode(false) + .HasColumnType("varchar(200)"); + + b.Property("ValidatorProperties") + .HasColumnType("nvarchar(max)"); + + b.Property("WorkGroupId") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("AggregationForId"); + + b.HasIndex("CategoryId"); + + b.HasIndex("DocumentId"); + + b.HasIndex("PartnerId"); + + b.HasIndex("ResponseSubscriptionId"); + + b.HasIndex("RetryPolicyId"); + + b.HasIndex("WorkGroupId"); + + b.ToTable("Subscriptions", (string)null); + }); + + modelBuilder.Entity("SW.Bitween.Domain.SubscriptionCategory", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("Code") + .HasColumnType("nvarchar(450)"); + + b.Property("CreatedBy") + .HasColumnType("nvarchar(max)"); + + b.Property("CreatedOn") + .HasColumnType("datetime2"); + + b.Property("Description") + .HasColumnType("nvarchar(max)"); + + b.Property("ModifiedBy") + .HasColumnType("nvarchar(max)"); + + b.Property("ModifiedOn") + .HasColumnType("datetime2"); + + b.HasKey("Id"); + + b.HasIndex("Code") + .IsUnique() + .HasFilter("[Code] IS NOT NULL"); + + b.ToTable("SubscriptionCategory"); + }); + + modelBuilder.Entity("SW.Bitween.Domain.SubscriptionTrail", b => + { + b.Property("Id") + .HasMaxLength(50) + .HasColumnType("nvarchar(50)"); + + b.Property("Code") + .HasColumnType("int"); + + b.Property("CreatedBy") + .HasColumnType("nvarchar(max)"); + + b.Property("CreatedOn") + .HasColumnType("datetime2"); + + b.Property("StateAfter") + .HasColumnType("nvarchar(max)"); + + b.Property("StateBefore") + .HasColumnType("nvarchar(max)"); + + b.Property("SubscriptionId") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("CreatedOn"); + + b.HasIndex("SubscriptionId"); + + b.ToTable("SubscriptionTrail"); + }); + + modelBuilder.Entity("SW.Bitween.Domain.WorkGroup", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("BusMessageName") + .IsRequired() + .HasMaxLength(100) + .IsUnicode(false) + .HasColumnType("varchar(100)"); + + b.Property("Name") + .HasColumnType("nvarchar(max)"); + + b.Property("Options") + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.ToTable("WorkGroup"); + }); + + modelBuilder.Entity("SW.Bitween.Domain.Xchange", b => + { + b.Property("Id") + .HasMaxLength(50) + .IsUnicode(false) + .HasColumnType("varchar(50)"); + + b.Property("CorrelationId") + .HasColumnType("nvarchar(max)"); + + b.Property("DocumentId") + .HasColumnType("int"); + + b.Property("HandlerId") + .HasMaxLength(200) + .IsUnicode(false) + .HasColumnType("varchar(200)"); + + b.Property("HandlerProperties") + .HasColumnType("nvarchar(max)"); + + b.Property("InputContentType") + .HasMaxLength(200) + .IsUnicode(false) + .HasColumnType("varchar(200)"); + + b.Property("InputHash") + .IsRequired() + .HasMaxLength(50) + .IsUnicode(false) + .HasColumnType("varchar(50)"); + + b.Property("InputName") + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("InputSize") + .HasColumnType("int"); + + b.Property("ManualRetry") + .HasColumnType("bit"); + + b.Property("MapperId") + .HasMaxLength(200) + .IsUnicode(false) + .HasColumnType("varchar(200)"); + + b.Property("MapperProperties") + .HasColumnType("nvarchar(max)"); + + b.Property("PartnerId") + .HasColumnType("int"); + + b.Property("References") + .HasMaxLength(1024) + .HasColumnType("nvarchar(1024)"); + + b.Property("ResponseMessageTypeName") + .HasMaxLength(500) + .IsUnicode(false) + .HasColumnType("varchar(500)"); + + b.Property("ResponseSubscriptionId") + .HasColumnType("int"); + + b.Property("RetryFor") + .HasMaxLength(50) + .IsUnicode(false) + .HasColumnType("varchar(50)"); + + b.Property("StartedOn") + .HasColumnType("datetime2"); + + b.Property("SubscriptionId") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("DocumentId"); + + b.HasIndex("InputHash"); + + b.HasIndex("RetryFor"); + + b.HasIndex("StartedOn"); + + b.HasIndex("SubscriptionId"); + + b.ToTable("Xchanges", (string)null); + }); + + modelBuilder.Entity("SW.Bitween.Domain.XchangeAggregation", b => + { + b.Property("Id") + .HasMaxLength(50) + .IsUnicode(false) + .HasColumnType("varchar(50)"); + + b.Property("AggregatedOn") + .HasColumnType("datetime2"); + + b.Property("AggregationXchangeId") + .IsRequired() + .HasMaxLength(50) + .IsUnicode(false) + .HasColumnType("varchar(50)"); + + b.HasKey("Id"); + + b.HasIndex("AggregationXchangeId"); + + b.ToTable("XchangeAggregations", (string)null); + }); + + modelBuilder.Entity("SW.Bitween.Domain.XchangeDelivery", b => + { + b.Property("Id") + .HasMaxLength(50) + .IsUnicode(false) + .HasColumnType("varchar(50)"); + + b.Property("DeliveredOn") + .HasColumnType("datetime2"); + + b.HasKey("Id"); + + b.HasIndex("DeliveredOn"); + + b.ToTable("XchangeDeliveries", (string)null); + }); + + modelBuilder.Entity("SW.Bitween.Domain.XchangeNotification", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("Exception") + .HasColumnType("nvarchar(max)"); + + b.Property("FinishedOn") + .HasColumnType("datetime2"); + + b.Property("NotifierId") + .HasColumnType("int"); + + b.Property("NotifierName") + .HasColumnType("nvarchar(max)"); + + b.Property("Success") + .HasColumnType("bit"); + + b.Property("XchangeId") + .HasMaxLength(50) + .IsUnicode(false) + .HasColumnType("varchar(50)"); + + b.HasKey("Id"); + + b.ToTable("XchangeNotifications", (string)null); + }); + + modelBuilder.Entity("SW.Bitween.Domain.XchangePromotedProperties", b => + { + b.Property("Id") + .HasMaxLength(50) + .IsUnicode(false) + .HasColumnType("varchar(50)"); + + b.Property("Hits") + .HasMaxLength(2000) + .IsUnicode(false) + .HasColumnType("varchar(2000)"); + + b.Property("Properties") + .HasColumnType("nvarchar(max)"); + + b.Property("PropertiesRaw") + .HasColumnType("nvarchar(450)"); + + b.HasKey("Id"); + + b.HasIndex("PropertiesRaw"); + + b.ToTable("XchangePromotedProperties", (string)null); + }); + + modelBuilder.Entity("SW.Bitween.Domain.XchangeResult", b => + { + b.Property("Id") + .HasMaxLength(50) + .IsUnicode(false) + .HasColumnType("varchar(50)"); + + b.Property("AttemptNumber") + .HasColumnType("int"); + + b.Property("Exception") + .HasColumnType("nvarchar(max)"); + + b.Property("FinishedOn") + .HasColumnType("datetime2"); + + b.Property("OutputBad") + .HasColumnType("bit"); + + b.Property("OutputContentType") + .HasMaxLength(200) + .IsUnicode(false) + .HasColumnType("varchar(200)"); + + b.Property("OutputHash") + .HasMaxLength(50) + .IsUnicode(false) + .HasColumnType("varchar(50)"); + + b.Property("OutputName") + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("OutputSize") + .HasColumnType("int"); + + b.Property("ResponseBad") + .HasColumnType("bit"); + + b.Property("ResponseContentType") + .HasMaxLength(200) + .IsUnicode(false) + .HasColumnType("varchar(200)"); + + b.Property("ResponseHash") + .HasMaxLength(50) + .IsUnicode(false) + .HasColumnType("varchar(50)"); + + b.Property("ResponseName") + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("ResponseSize") + .HasColumnType("int"); + + b.Property("ResponseXchangeId") + .HasColumnType("nvarchar(max)"); + + b.Property("RetryBlockedReason") + .HasMaxLength(500) + .HasColumnType("nvarchar(500)"); + + b.Property("RetryGroupId") + .HasColumnType("uniqueidentifier"); + + b.Property("Success") + .HasColumnType("bit"); + + b.HasKey("Id"); + + b.HasIndex("RetryGroupId"); + + b.ToTable("XchangeResults", (string)null); + }); + + modelBuilder.Entity("SW.Bitween.RunFlagUpdater+RunningResult", b => + { + b.Property("IsRunning") + .HasColumnType("bit"); + + b.ToTable((string)null); + + b.ToView(null, (string)null); + }); + + modelBuilder.Entity("SW.Scheduler.JobExecution", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint") + .HasColumnName("id"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("Context") + .HasColumnType("nvarchar(max)") + .HasColumnName("context"); + + b.Property("DurationMs") + .HasColumnType("bigint") + .HasColumnName("duration_ms"); + + b.Property("EndTimeUtc") + .HasColumnType("datetime2") + .HasColumnName("end_time_utc"); + + b.Property("Error") + .HasColumnType("nvarchar(max)") + .HasColumnName("error"); + + b.Property("FireInstanceId") + .IsRequired() + .HasColumnType("nvarchar(450)") + .HasColumnName("fire_instance_id"); + + b.Property("JobGroup") + .IsRequired() + .HasColumnType("nvarchar(450)") + .HasColumnName("job_group"); + + b.Property("JobName") + .IsRequired() + .HasColumnType("nvarchar(450)") + .HasColumnName("job_name"); + + b.Property("JobTypeName") + .IsRequired() + .HasColumnType("nvarchar(450)") + .HasColumnName("job_type_name"); + + b.Property("Node") + .IsRequired() + .HasColumnType("nvarchar(450)") + .HasColumnName("node"); + + b.Property("StartTimeUtc") + .HasColumnType("datetime2") + .HasColumnName("start_time_utc"); + + b.Property("Success") + .HasColumnType("bit") + .HasColumnName("success"); + + b.HasKey("Id"); + + b.HasIndex("FireInstanceId") + .IsUnique() + .HasDatabaseName("idx_je_fire_instance_id"); + + b.HasIndex("StartTimeUtc") + .HasDatabaseName("idx_je_start_time"); + + b.HasIndex("Success") + .HasDatabaseName("idx_je_success"); + + b.HasIndex("JobGroup", "JobName", "StartTimeUtc") + .HasDatabaseName("idx_je_group_name_start"); + + b.ToTable("job_executions", "dbo"); + }); + + modelBuilder.Entity("SW.Scheduler.QuartzBlobTrigger", b => + { + b.Property("SchedulerName") + .HasColumnType("nvarchar(450)") + .HasColumnName("sched_name"); + + b.Property("TriggerName") + .HasColumnType("nvarchar(450)") + .HasColumnName("trigger_name"); + + b.Property("TriggerGroup") + .HasColumnType("nvarchar(450)") + .HasColumnName("trigger_group"); + + b.Property("BlobData") + .HasColumnType("varbinary(max)") + .HasColumnName("blob_data"); + + b.HasKey("SchedulerName", "TriggerName", "TriggerGroup"); + + b.ToTable("QRTZ_blob_triggers", "dbo"); + }); + + modelBuilder.Entity("SW.Scheduler.QuartzCalendar", b => + { + b.Property("SchedulerName") + .HasColumnType("nvarchar(450)") + .HasColumnName("sched_name"); + + b.Property("CalendarName") + .HasColumnType("nvarchar(450)") + .HasColumnName("calendar_name"); + + b.Property("Calendar") + .IsRequired() + .HasColumnType("varbinary(max)") + .HasColumnName("calendar"); + + b.HasKey("SchedulerName", "CalendarName"); + + b.ToTable("QRTZ_calendars", "dbo"); + }); + + modelBuilder.Entity("SW.Scheduler.QuartzCronTrigger", b => + { + b.Property("SchedulerName") + .HasColumnType("nvarchar(450)") + .HasColumnName("sched_name"); + + b.Property("TriggerName") + .HasColumnType("nvarchar(450)") + .HasColumnName("trigger_name"); + + b.Property("TriggerGroup") + .HasColumnType("nvarchar(450)") + .HasColumnName("trigger_group"); + + b.Property("CronExpression") + .IsRequired() + .HasColumnType("nvarchar(450)") + .HasColumnName("cron_expression"); + + b.Property("TimeZoneId") + .HasColumnType("nvarchar(450)") + .HasColumnName("time_zone_id"); + + b.HasKey("SchedulerName", "TriggerName", "TriggerGroup"); + + b.ToTable("QRTZ_cron_triggers", "dbo"); + }); + + modelBuilder.Entity("SW.Scheduler.QuartzFiredTrigger", b => + { + b.Property("SchedulerName") + .HasColumnType("nvarchar(450)") + .HasColumnName("sched_name"); + + b.Property("EntryId") + .HasColumnType("nvarchar(450)") + .HasColumnName("entry_id"); + + b.Property("FiredTime") + .HasColumnType("bigint") + .HasColumnName("fired_time"); + + b.Property("InstanceName") + .IsRequired() + .HasColumnType("nvarchar(450)") + .HasColumnName("instance_name"); + + b.Property("IsNonConcurrent") + .HasColumnType("bit") + .HasColumnName("is_nonconcurrent"); + + b.Property("JobGroup") + .HasColumnType("nvarchar(450)") + .HasColumnName("job_group"); + + b.Property("JobName") + .HasColumnType("nvarchar(450)") + .HasColumnName("job_name"); + + b.Property("Priority") + .HasColumnType("int") + .HasColumnName("priority"); + + b.Property("RequestsRecovery") + .HasColumnType("bit") + .HasColumnName("requests_recovery"); + + b.Property("ScheduledTime") + .HasColumnType("bigint") + .HasColumnName("sched_time"); + + b.Property("State") + .IsRequired() + .HasColumnType("nvarchar(450)") + .HasColumnName("state"); + + b.Property("TriggerGroup") + .IsRequired() + .HasColumnType("nvarchar(450)") + .HasColumnName("trigger_group"); + + b.Property("TriggerName") + .IsRequired() + .HasColumnType("nvarchar(450)") + .HasColumnName("trigger_name"); + + b.HasKey("SchedulerName", "EntryId"); + + b.HasIndex("InstanceName") + .HasDatabaseName("idx_QRTZ_ft_trig_inst_name"); + + b.HasIndex("JobGroup") + .HasDatabaseName("idx_QRTZ_ft_job_group"); + + b.HasIndex("JobName") + .HasDatabaseName("idx_QRTZ_ft_job_name"); + + b.HasIndex("RequestsRecovery") + .HasDatabaseName("idx_QRTZ_ft_job_req_recovery"); + + b.HasIndex("TriggerGroup") + .HasDatabaseName("idx_QRTZ_ft_trig_group"); + + b.HasIndex("TriggerName") + .HasDatabaseName("idx_QRTZ_ft_trig_name"); + + b.HasIndex("SchedulerName", "TriggerName", "TriggerGroup") + .HasDatabaseName("idx_QRTZ_ft_trig_nm_gp"); + + b.ToTable("QRTZ_fired_triggers", "dbo"); + }); + + modelBuilder.Entity("SW.Scheduler.QuartzJobDetail", b => + { + b.Property("SchedulerName") + .HasColumnType("nvarchar(450)") + .HasColumnName("sched_name"); + + b.Property("JobName") + .HasColumnType("nvarchar(450)") + .HasColumnName("job_name"); + + b.Property("JobGroup") + .HasColumnType("nvarchar(450)") + .HasColumnName("job_group"); + + b.Property("Description") + .HasColumnType("nvarchar(450)") + .HasColumnName("description"); + + b.Property("IsDurable") + .HasColumnType("bit") + .HasColumnName("is_durable"); + + b.Property("IsNonConcurrent") + .HasColumnType("bit") + .HasColumnName("is_nonconcurrent"); + + b.Property("IsUpdateData") + .HasColumnType("bit") + .HasColumnName("is_update_data"); + + b.Property("JobClassName") + .IsRequired() + .HasColumnType("nvarchar(450)") + .HasColumnName("job_class_name"); + + b.Property("JobData") + .HasColumnType("varbinary(max)") + .HasColumnName("job_data"); + + b.Property("RequestsRecovery") + .HasColumnType("bit") + .HasColumnName("requests_recovery"); + + b.HasKey("SchedulerName", "JobName", "JobGroup"); + + b.HasIndex("RequestsRecovery") + .HasDatabaseName("idx_j_req_recovery"); + + b.ToTable("QRTZ_job_details", "dbo"); + }); + + modelBuilder.Entity("SW.Scheduler.QuartzLock", b => + { + b.Property("SchedulerName") + .HasColumnType("nvarchar(450)") + .HasColumnName("sched_name"); + + b.Property("LockName") + .HasColumnType("nvarchar(450)") + .HasColumnName("lock_name"); + + b.HasKey("SchedulerName", "LockName"); + + b.ToTable("QRTZ_locks", "dbo"); + }); + + modelBuilder.Entity("SW.Scheduler.QuartzPausedTriggerGroup", b => + { + b.Property("SchedulerName") + .HasColumnType("nvarchar(450)") + .HasColumnName("sched_name"); + + b.Property("TriggerGroup") + .HasColumnType("nvarchar(450)") + .HasColumnName("trigger_group"); + + b.HasKey("SchedulerName", "TriggerGroup"); + + b.ToTable("QRTZ_paused_trigger_grps", "dbo"); + }); + + modelBuilder.Entity("SW.Scheduler.QuartzSchedulerState", b => + { + b.Property("SchedulerName") + .HasColumnType("nvarchar(450)") + .HasColumnName("sched_name"); + + b.Property("InstanceName") + .HasColumnType("nvarchar(450)") + .HasColumnName("instance_name"); + + b.Property("CheckInInterval") + .HasColumnType("bigint") + .HasColumnName("checkin_interval"); + + b.Property("LastCheckInTime") + .HasColumnType("bigint") + .HasColumnName("last_checkin_time"); + + b.HasKey("SchedulerName", "InstanceName"); + + b.ToTable("QRTZ_scheduler_state", "dbo"); + }); + + modelBuilder.Entity("SW.Scheduler.QuartzSimplePropertyTrigger", b => + { + b.Property("SchedulerName") + .HasColumnType("nvarchar(450)") + .HasColumnName("sched_name"); + + b.Property("TriggerName") + .HasColumnType("nvarchar(450)") + .HasColumnName("trigger_name"); + + b.Property("TriggerGroup") + .HasColumnType("nvarchar(450)") + .HasColumnName("trigger_group"); + + b.Property("BooleanProperty1") + .HasColumnType("bit") + .HasColumnName("bool_prop_1"); + + b.Property("BooleanProperty2") + .HasColumnType("bit") + .HasColumnName("bool_prop_2"); + + b.Property("DecimalProperty1") + .HasColumnType("numeric") + .HasColumnName("dec_prop_1"); + + b.Property("DecimalProperty2") + .HasColumnType("numeric") + .HasColumnName("dec_prop_2"); + + b.Property("IntegerProperty1") + .HasColumnType("int") + .HasColumnName("int_prop_1"); + + b.Property("IntegerProperty2") + .HasColumnType("int") + .HasColumnName("int_prop_2"); + + b.Property("LongProperty1") + .HasColumnType("bigint") + .HasColumnName("long_prop_1"); + + b.Property("LongProperty2") + .HasColumnType("bigint") + .HasColumnName("long_prop_2"); + + b.Property("StringProperty1") + .HasColumnType("nvarchar(450)") + .HasColumnName("str_prop_1"); + + b.Property("StringProperty2") + .HasColumnType("nvarchar(450)") + .HasColumnName("str_prop_2"); + + b.Property("StringProperty3") + .HasColumnType("nvarchar(450)") + .HasColumnName("str_prop_3"); + + b.Property("TimeZoneId") + .HasColumnType("nvarchar(450)") + .HasColumnName("time_zone_id"); + + b.HasKey("SchedulerName", "TriggerName", "TriggerGroup"); + + b.ToTable("QRTZ_simprop_triggers", "dbo"); + }); + + modelBuilder.Entity("SW.Scheduler.QuartzSimpleTrigger", b => + { + b.Property("SchedulerName") + .HasColumnType("nvarchar(450)") + .HasColumnName("sched_name"); + + b.Property("TriggerName") + .HasColumnType("nvarchar(450)") + .HasColumnName("trigger_name"); + + b.Property("TriggerGroup") + .HasColumnType("nvarchar(450)") + .HasColumnName("trigger_group"); + + b.Property("RepeatCount") + .HasColumnType("bigint") + .HasColumnName("repeat_count"); + + b.Property("RepeatInterval") + .HasColumnType("bigint") + .HasColumnName("repeat_interval"); + + b.Property("TimesTriggered") + .HasColumnType("bigint") + .HasColumnName("times_triggered"); + + b.HasKey("SchedulerName", "TriggerName", "TriggerGroup"); + + b.ToTable("QRTZ_simple_triggers", "dbo"); + }); + + modelBuilder.Entity("SW.Scheduler.QuartzTrigger", b => + { + b.Property("SchedulerName") + .HasColumnType("nvarchar(450)") + .HasColumnName("sched_name"); + + b.Property("TriggerName") + .HasColumnType("nvarchar(450)") + .HasColumnName("trigger_name"); + + b.Property("TriggerGroup") + .HasColumnType("nvarchar(450)") + .HasColumnName("trigger_group"); + + b.Property("CalendarName") + .HasColumnType("nvarchar(450)") + .HasColumnName("calendar_name"); + + b.Property("Description") + .HasColumnType("nvarchar(450)") + .HasColumnName("description"); + + b.Property("EndTime") + .HasColumnType("bigint") + .HasColumnName("end_time"); + + b.Property("JobData") + .HasColumnType("varbinary(max)") + .HasColumnName("job_data"); + + b.Property("JobGroup") + .IsRequired() + .HasColumnType("nvarchar(450)") + .HasColumnName("job_group"); + + b.Property("JobName") + .IsRequired() + .HasColumnType("nvarchar(450)") + .HasColumnName("job_name"); + + b.Property("MisfireInstruction") + .HasColumnType("int") + .HasColumnName("misfire_instr"); + + b.Property("NextFireTime") + .HasColumnType("bigint") + .HasColumnName("next_fire_time"); + + b.Property("PreviousFireTime") + .HasColumnType("bigint") + .HasColumnName("prev_fire_time"); + + b.Property("Priority") + .HasColumnType("int") + .HasColumnName("priority"); + + b.Property("StartTime") + .HasColumnType("bigint") + .HasColumnName("start_time"); + + b.Property("TriggerState") + .IsRequired() + .HasColumnType("nvarchar(450)") + .HasColumnName("trigger_state"); + + b.Property("TriggerType") + .IsRequired() + .HasColumnType("nvarchar(450)") + .HasColumnName("trigger_type"); + + b.HasKey("SchedulerName", "TriggerName", "TriggerGroup"); + + b.HasIndex("NextFireTime") + .HasDatabaseName("idx_t_next_fire_time"); + + b.HasIndex("TriggerState") + .HasDatabaseName("idx_t_state"); + + b.HasIndex("NextFireTime", "TriggerState") + .HasDatabaseName("idx_t_nft_st"); + + b.HasIndex("SchedulerName", "JobName", "JobGroup"); + + b.ToTable("QRTZ_triggers", "dbo"); + }); + + modelBuilder.Entity("SW.Bitween.Domain.Accounts.AccountRoleLink", b => + { + b.HasOne("SW.Bitween.Domain.Accounts.Account", null) + .WithMany() + .HasForeignKey("AccountId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("SW.Bitween.Domain.Accounts.Role", null) + .WithMany() + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("SW.Bitween.Domain.Accounts.RefreshToken", b => + { + b.HasOne("SW.Bitween.Domain.Accounts.Account", null) + .WithMany() + .HasForeignKey("AccountId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("SW.Bitween.Domain.DocumentTrail", b => + { + b.HasOne("SW.Bitween.Domain.Document", "Document") + .WithMany() + .HasForeignKey("DocumentId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Document"); + }); + + modelBuilder.Entity("SW.Bitween.Domain.Gateway.ApiGatewayPartner", b => + { + b.HasOne("SW.Bitween.Domain.Gateway.ApiGateway", "ApiGateway") + .WithMany("Partners") + .HasForeignKey("ApiGatewayId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("SW.Bitween.Domain.Partner", "Partner") + .WithMany() + .HasForeignKey("PartnerId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("SW.Bitween.Domain.Subscription", "Subscription") + .WithMany() + .HasForeignKey("SubscriptionId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("ApiGateway"); + + b.Navigation("Partner"); + + b.Navigation("Subscription"); + }); + + modelBuilder.Entity("SW.Bitween.Domain.Gateway.BusGateway", b => + { + b.HasOne("SW.Bitween.Domain.Document", null) + .WithMany() + .HasForeignKey("DocumentId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("SW.Bitween.Domain.Gateway.BusGatewayRoute", b => + { + b.HasOne("SW.Bitween.Domain.Gateway.BusGateway", "BusGateway") + .WithMany("Routes") + .HasForeignKey("BusGatewayId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("SW.Bitween.Domain.Partner", "Partner") + .WithMany() + .HasForeignKey("PartnerId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("SW.Bitween.Domain.Subscription", "Subscription") + .WithMany() + .HasForeignKey("SubscriptionId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("BusGateway"); + + b.Navigation("Partner"); + + b.Navigation("Subscription"); + }); + + modelBuilder.Entity("SW.Bitween.Domain.Partner", b => + { + b.OwnsMany("SW.Bitween.Domain.ApiCredential", "ApiCredentials", b1 => + { + b1.Property("PartnerId") + .HasColumnType("int"); + + b1.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b1.Property("Id")); + + b1.Property("Key") + .IsRequired() + .HasMaxLength(500) + .IsUnicode(false) + .HasColumnType("varchar(500)"); + + b1.Property("Name") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("nvarchar(500)"); + + b1.HasKey("PartnerId", "Id"); + + b1.HasIndex("Key") + .IsUnique(); + + b1.ToTable("PartnerApiCredentials", (string)null); + + b1.WithOwner() + .HasForeignKey("PartnerId"); + + b1.HasData( + new + { + PartnerId = 1, + Id = 1, + Key = "7facc758283844b49cc4ffd26a75b1de", + Name = "default" + }); + }); + + b.Navigation("ApiCredentials"); + }); + + modelBuilder.Entity("SW.Bitween.Domain.Subscription", b => + { + b.HasOne("SW.Bitween.Domain.Subscription", null) + .WithMany() + .HasForeignKey("AggregationForId") + .OnDelete(DeleteBehavior.Restrict) + .HasConstraintName("FK_Subscriptions_AggFor"); + + b.HasOne("SW.Bitween.Domain.SubscriptionCategory", "Category") + .WithMany() + .HasForeignKey("CategoryId"); + + b.HasOne("SW.Bitween.Domain.Document", null) + .WithMany() + .HasForeignKey("DocumentId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("SW.Bitween.Domain.Partner", null) + .WithMany("Subscriptions") + .HasForeignKey("PartnerId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("SW.Bitween.Domain.Subscription", null) + .WithMany() + .HasForeignKey("ResponseSubscriptionId") + .OnDelete(DeleteBehavior.Restrict) + .HasConstraintName("FK_Subscriptions_RespSub"); + + b.HasOne("SW.Bitween.Domain.RetryPolicy", "RetryPolicy") + .WithMany() + .HasForeignKey("RetryPolicyId") + .OnDelete(DeleteBehavior.SetNull); + + b.HasOne("SW.Bitween.Domain.WorkGroup", "WorkGroup") + .WithMany() + .HasForeignKey("WorkGroupId"); + + b.OwnsMany("SW.Bitween.Domain.Schedule", "Schedules", b1 => + { + b1.Property("SubscriptionId") + .HasColumnType("int"); + + b1.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b1.Property("Id")); + + b1.Property("Backwards") + .HasColumnType("bit"); + + b1.Property("On") + .HasColumnType("bigint"); + + b1.Property("Recurrence") + .HasColumnType("tinyint"); + + b1.HasKey("SubscriptionId", "Id"); + + b1.ToTable("SubscriptionSchedules", (string)null); + + b1.WithOwner() + .HasForeignKey("SubscriptionId"); + }); + + b.Navigation("Category"); + + b.Navigation("RetryPolicy"); + + b.Navigation("Schedules"); + + b.Navigation("WorkGroup"); + }); + + modelBuilder.Entity("SW.Bitween.Domain.SubscriptionTrail", b => + { + b.HasOne("SW.Bitween.Domain.Subscription", "Subscription") + .WithMany() + .HasForeignKey("SubscriptionId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Subscription"); + }); + + modelBuilder.Entity("SW.Bitween.Domain.Xchange", b => + { + b.HasOne("SW.Bitween.Domain.Document", null) + .WithMany() + .HasForeignKey("DocumentId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("SW.Bitween.Domain.XchangeAggregation", b => + { + b.HasOne("SW.Bitween.Domain.Xchange", null) + .WithOne() + .HasForeignKey("SW.Bitween.Domain.XchangeAggregation", "Id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("SW.Bitween.Domain.XchangeDelivery", b => + { + b.HasOne("SW.Bitween.Domain.Xchange", null) + .WithOne() + .HasForeignKey("SW.Bitween.Domain.XchangeDelivery", "Id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("SW.Bitween.Domain.XchangePromotedProperties", b => + { + b.HasOne("SW.Bitween.Domain.Xchange", null) + .WithOne() + .HasForeignKey("SW.Bitween.Domain.XchangePromotedProperties", "Id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("SW.Bitween.Domain.XchangeResult", b => + { + b.HasOne("SW.Bitween.Domain.Xchange", null) + .WithOne() + .HasForeignKey("SW.Bitween.Domain.XchangeResult", "Id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("SW.Scheduler.QuartzBlobTrigger", b => + { + b.HasOne("SW.Scheduler.QuartzTrigger", "Trigger") + .WithMany("BlobTriggers") + .HasForeignKey("SchedulerName", "TriggerName", "TriggerGroup") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Trigger"); + }); + + modelBuilder.Entity("SW.Scheduler.QuartzCronTrigger", b => + { + b.HasOne("SW.Scheduler.QuartzTrigger", "Trigger") + .WithMany("CronTriggers") + .HasForeignKey("SchedulerName", "TriggerName", "TriggerGroup") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Trigger"); + }); + + modelBuilder.Entity("SW.Scheduler.QuartzSimplePropertyTrigger", b => + { + b.HasOne("SW.Scheduler.QuartzTrigger", "Trigger") + .WithMany("SimplePropertyTriggers") + .HasForeignKey("SchedulerName", "TriggerName", "TriggerGroup") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Trigger"); + }); + + modelBuilder.Entity("SW.Scheduler.QuartzSimpleTrigger", b => + { + b.HasOne("SW.Scheduler.QuartzTrigger", "Trigger") + .WithMany("SimpleTriggers") + .HasForeignKey("SchedulerName", "TriggerName", "TriggerGroup") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Trigger"); + }); + + modelBuilder.Entity("SW.Scheduler.QuartzTrigger", b => + { + b.HasOne("SW.Scheduler.QuartzJobDetail", "JobDetail") + .WithMany("Triggers") + .HasForeignKey("SchedulerName", "JobName", "JobGroup") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("JobDetail"); + }); + + modelBuilder.Entity("SW.Bitween.Domain.Gateway.ApiGateway", b => + { + b.Navigation("Partners"); + }); + + modelBuilder.Entity("SW.Bitween.Domain.Gateway.BusGateway", b => + { + b.Navigation("Routes"); + }); + + modelBuilder.Entity("SW.Bitween.Domain.Partner", b => + { + b.Navigation("Subscriptions"); + }); + + modelBuilder.Entity("SW.Scheduler.QuartzJobDetail", b => + { + b.Navigation("Triggers"); + }); + + modelBuilder.Entity("SW.Scheduler.QuartzTrigger", b => + { + b.Navigation("BlobTriggers"); + + b.Navigation("CronTriggers"); + + b.Navigation("SimplePropertyTriggers"); + + b.Navigation("SimpleTriggers"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/SW.Bitween.MsSql/Migrations/20260826101529_FixDefaultCreatedOnTimezone.cs b/SW.Bitween.MsSql/Migrations/20260826101529_FixDefaultCreatedOnTimezone.cs new file mode 100644 index 00000000..5cfe1033 --- /dev/null +++ b/SW.Bitween.MsSql/Migrations/20260826101529_FixDefaultCreatedOnTimezone.cs @@ -0,0 +1,75 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace SW.Bitween.MsSql.Migrations +{ + /// + public partial class FixDefaultCreatedOnTimezone : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.UpdateData( + table: "Accounts", + keyColumn: "Id", + keyValue: 9999, + column: "CreatedOn", + value: new DateTime(2022, 1, 1, 0, 0, 0, 0, DateTimeKind.Utc)); + + migrationBuilder.UpdateData( + table: "Roles", + keyColumn: "Id", + keyValue: 1, + column: "CreatedOn", + value: new DateTime(2022, 1, 1, 0, 0, 0, 0, DateTimeKind.Utc)); + + migrationBuilder.UpdateData( + table: "Roles", + keyColumn: "Id", + keyValue: 2, + column: "CreatedOn", + value: new DateTime(2022, 1, 1, 0, 0, 0, 0, DateTimeKind.Utc)); + + migrationBuilder.UpdateData( + table: "Roles", + keyColumn: "Id", + keyValue: 3, + column: "CreatedOn", + value: new DateTime(2022, 1, 1, 0, 0, 0, 0, DateTimeKind.Utc)); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.UpdateData( + table: "Accounts", + keyColumn: "Id", + keyValue: 9999, + column: "CreatedOn", + value: new DateTime(2021, 12, 31, 22, 0, 0, 0, DateTimeKind.Utc)); + + migrationBuilder.UpdateData( + table: "Roles", + keyColumn: "Id", + keyValue: 1, + column: "CreatedOn", + value: new DateTime(2021, 12, 31, 22, 0, 0, 0, DateTimeKind.Utc)); + + migrationBuilder.UpdateData( + table: "Roles", + keyColumn: "Id", + keyValue: 2, + column: "CreatedOn", + value: new DateTime(2021, 12, 31, 22, 0, 0, 0, DateTimeKind.Utc)); + + migrationBuilder.UpdateData( + table: "Roles", + keyColumn: "Id", + keyValue: 3, + column: "CreatedOn", + value: new DateTime(2021, 12, 31, 22, 0, 0, 0, DateTimeKind.Utc)); + } + } +} diff --git a/SW.Bitween.MsSql/Migrations/BitweenDbContextModelSnapshot.cs b/SW.Bitween.MsSql/Migrations/BitweenDbContextModelSnapshot.cs index de43d8c9..4af58e1a 100644 --- a/SW.Bitween.MsSql/Migrations/BitweenDbContextModelSnapshot.cs +++ b/SW.Bitween.MsSql/Migrations/BitweenDbContextModelSnapshot.cs @@ -17,7 +17,7 @@ protected override void BuildModel(ModelBuilder modelBuilder) { #pragma warning disable 612, 618 modelBuilder - .HasAnnotation("ProductVersion", "8.0.23") + .HasAnnotation("ProductVersion", "9.0.19") .HasAnnotation("Relational:MaxIdentifierLength", 128); SqlServerModelBuilderExtensions.UseIdentityColumns(modelBuilder); @@ -92,7 +92,7 @@ protected override void BuildModel(ModelBuilder modelBuilder) new { Id = 9999, - CreatedOn = new DateTime(2021, 12, 31, 22, 0, 0, 0, DateTimeKind.Utc), + CreatedOn = new DateTime(2022, 1, 1, 0, 0, 0, 0, DateTimeKind.Utc), Deleted = false, Disabled = false, DisplayName = "Admin", @@ -189,7 +189,7 @@ protected override void BuildModel(ModelBuilder modelBuilder) new { Id = 1, - CreatedOn = new DateTime(2021, 12, 31, 22, 0, 0, 0, DateTimeKind.Utc), + CreatedOn = new DateTime(2022, 1, 1, 0, 0, 0, 0, DateTimeKind.Utc), Description = "Full access to everything, including members, roles and settings.", IsSystem = true, Name = "Administrator", @@ -198,7 +198,7 @@ protected override void BuildModel(ModelBuilder modelBuilder) new { Id = 2, - CreatedOn = new DateTime(2021, 12, 31, 22, 0, 0, 0, DateTimeKind.Utc), + CreatedOn = new DateTime(2022, 1, 1, 0, 0, 0, 0, DateTimeKind.Utc), Description = "Runs and configures integrations. Can't manage members, roles or settings.", IsSystem = true, Name = "Member", @@ -207,7 +207,7 @@ protected override void BuildModel(ModelBuilder modelBuilder) new { Id = 3, - CreatedOn = new DateTime(2021, 12, 31, 22, 0, 0, 0, DateTimeKind.Utc), + CreatedOn = new DateTime(2022, 1, 1, 0, 0, 0, 0, DateTimeKind.Utc), Description = "Read-only access to integrations, exchanges and configuration.", IsSystem = true, Name = "Viewer", diff --git a/SW.Bitween.MySql/Migrations/20260826101534_FixDefaultCreatedOnTimezone.Designer.cs b/SW.Bitween.MySql/Migrations/20260826101534_FixDefaultCreatedOnTimezone.Designer.cs new file mode 100644 index 00000000..13826f2b --- /dev/null +++ b/SW.Bitween.MySql/Migrations/20260826101534_FixDefaultCreatedOnTimezone.Designer.cs @@ -0,0 +1,2131 @@ +// +using System; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Metadata; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using SW.Bitween.MySql; + +#nullable disable + +namespace SW.Bitween.MySql.Migrations +{ + [DbContext(typeof(BitweenDbContext))] + [Migration("20260826101534_FixDefaultCreatedOnTimezone")] + partial class FixDefaultCreatedOnTimezone + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "9.0.19") + .HasAnnotation("Relational:MaxIdentifierLength", 64); + + MySqlModelBuilderExtensions.AutoIncrementColumns(modelBuilder); + + modelBuilder.Entity("SW.Bitween.Domain.Accounts.Account", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("Id")); + + b.Property("CreatedBy") + .HasColumnType("longtext"); + + b.Property("CreatedOn") + .HasColumnType("datetime(6)"); + + b.Property("Deleted") + .HasColumnType("tinyint(1)"); + + b.Property("Disabled") + .HasColumnType("tinyint(1)"); + + b.Property("DisplayName") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("varchar(200)"); + + b.Property("Email") + .HasMaxLength(200) + .IsUnicode(false) + .HasColumnType("varchar(200)"); + + b.Property("EmailProvider") + .HasColumnType("tinyint unsigned"); + + b.Property("FailedLoginCount") + .HasColumnType("int"); + + b.Property("LockoutEnd") + .HasColumnType("datetime(6)"); + + b.Property("LoginMethods") + .HasColumnType("tinyint unsigned"); + + b.Property("ModifiedBy") + .HasColumnType("longtext"); + + b.Property("ModifiedOn") + .HasColumnType("datetime(6)"); + + b.Property("Password") + .HasMaxLength(500) + .IsUnicode(false) + .HasColumnType("varchar(500)"); + + b.Property("Role") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("Email") + .IsUnique(); + + b.ToTable("Accounts", (string)null); + + b.HasData( + new + { + Id = 9999, + CreatedOn = new DateTime(2022, 1, 1, 0, 0, 0, 0, DateTimeKind.Utc), + Deleted = false, + Disabled = false, + DisplayName = "Admin", + Email = "admin@Bitween.systems", + EmailProvider = (byte)0, + FailedLoginCount = 0, + LoginMethods = (byte)2, + Password = "$SWHASH$V1$10000$VQCi48eitH4Ml5juvBMOFZrMdQwBbhuIQVXe6RR7qJdDF2bJ", + Role = 0 + }); + }); + + modelBuilder.Entity("SW.Bitween.Domain.Accounts.AccountRoleLink", b => + { + b.Property("AccountId") + .HasColumnType("int"); + + b.Property("RoleId") + .HasColumnType("int"); + + b.HasKey("AccountId", "RoleId"); + + b.HasIndex("RoleId"); + + b.ToTable("AccountRoles", (string)null); + }); + + modelBuilder.Entity("SW.Bitween.Domain.Accounts.RefreshToken", b => + { + b.Property("Id") + .HasMaxLength(50) + .IsUnicode(false) + .HasColumnType("varchar(50)"); + + b.Property("AccountId") + .HasColumnType("int"); + + b.Property("CreatedOn") + .HasColumnType("datetime(6)"); + + b.Property("LoginMethod") + .HasColumnType("tinyint unsigned"); + + b.HasKey("Id"); + + b.HasIndex("AccountId"); + + b.ToTable("RefreshTokens", (string)null); + }); + + modelBuilder.Entity("SW.Bitween.Domain.Accounts.Role", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("Id")); + + b.Property("CreatedBy") + .HasColumnType("longtext"); + + b.Property("CreatedOn") + .HasColumnType("datetime(6)"); + + b.Property("Description") + .HasMaxLength(500) + .HasColumnType("varchar(500)"); + + b.Property("IsSystem") + .HasColumnType("tinyint(1)"); + + b.Property("ModifiedBy") + .HasColumnType("longtext"); + + b.Property("ModifiedOn") + .HasColumnType("datetime(6)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("varchar(100)"); + + b.Property("Permissions") + .HasColumnType("longtext"); + + b.HasKey("Id"); + + b.HasIndex("Name") + .IsUnique(); + + b.ToTable("Roles", (string)null); + + b.HasData( + new + { + Id = 1, + CreatedOn = new DateTime(2022, 1, 1, 0, 0, 0, 0, DateTimeKind.Utc), + Description = "Full access to everything, including members, roles and settings.", + IsSystem = true, + Name = "Administrator", + Permissions = "[]" + }, + new + { + Id = 2, + CreatedOn = new DateTime(2022, 1, 1, 0, 0, 0, 0, DateTimeKind.Utc), + Description = "Runs and configures integrations. Can't manage members, roles or settings.", + IsSystem = true, + Name = "Member", + Permissions = "[]" + }, + new + { + Id = 3, + CreatedOn = new DateTime(2022, 1, 1, 0, 0, 0, 0, DateTimeKind.Utc), + Description = "Read-only access to integrations, exchanges and configuration.", + IsSystem = true, + Name = "Viewer", + Permissions = "[]" + }); + }); + + modelBuilder.Entity("SW.Bitween.Domain.DelayedRetry", b => + { + b.Property("Id") + .HasMaxLength(50) + .IsUnicode(false) + .HasColumnType("varchar(50)"); + + b.Property("On") + .HasColumnType("datetime(6)"); + + b.HasKey("Id"); + + b.HasIndex("On"); + + b.ToTable("DelayedRetries", (string)null); + }); + + modelBuilder.Entity("SW.Bitween.Domain.Document", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("Id")); + + b.Property("BusEnabled") + .HasColumnType("tinyint(1)"); + + b.Property("BusMessageTypeName") + .HasMaxLength(500) + .IsUnicode(false) + .HasColumnType("varchar(500)"); + + b.Property("Code") + .HasMaxLength(50) + .IsUnicode(false) + .HasColumnType("varchar(50)"); + + b.Property("DisregardsUnfilteredMessages") + .HasColumnType("tinyint(1)"); + + b.Property("DocumentFormat") + .HasColumnType("int"); + + b.Property("DuplicateInterval") + .HasColumnType("int"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .IsUnicode(false) + .HasColumnType("varchar(100)"); + + b.Property("PromotedProperties") + .HasColumnType("longtext"); + + b.HasKey("Id"); + + b.HasIndex("BusMessageTypeName") + .IsUnique(); + + b.HasIndex("Code") + .IsUnique(); + + b.HasIndex("Name") + .IsUnique(); + + b.ToTable("Documents", (string)null); + + b.HasData( + new + { + Id = 10001, + BusEnabled = false, + DocumentFormat = 0, + DuplicateInterval = 0, + Name = "Aggregation Document", + PromotedProperties = "{}" + }); + }); + + modelBuilder.Entity("SW.Bitween.Domain.DocumentTrail", b => + { + b.Property("Id") + .HasMaxLength(50) + .HasColumnType("varchar(50)"); + + b.Property("Code") + .HasColumnType("int"); + + b.Property("CreatedBy") + .HasColumnType("longtext"); + + b.Property("CreatedOn") + .HasColumnType("datetime(6)"); + + b.Property("DocumentId") + .HasColumnType("int"); + + b.Property("StateAfter") + .HasColumnType("longtext"); + + b.Property("StateBefore") + .HasColumnType("longtext"); + + b.HasKey("Id"); + + b.HasIndex("CreatedOn"); + + b.HasIndex("DocumentId"); + + b.ToTable("DocumentTrail"); + }); + + modelBuilder.Entity("SW.Bitween.Domain.Gateway.ApiGateway", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("Id")); + + b.Property("CreatedBy") + .HasColumnType("longtext"); + + b.Property("CreatedOn") + .HasColumnType("datetime(6)"); + + b.Property("Inactive") + .HasColumnType("tinyint(1)"); + + b.Property("ModifiedBy") + .HasColumnType("longtext"); + + b.Property("ModifiedOn") + .HasColumnType("datetime(6)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("varchar(200)"); + + b.Property("UrlName") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("varchar(200)"); + + b.HasKey("Id"); + + b.HasIndex("UrlName") + .IsUnique(); + + b.ToTable("ApiGateways", (string)null); + }); + + modelBuilder.Entity("SW.Bitween.Domain.Gateway.ApiGatewayPartner", b => + { + b.Property("ApiGatewayId") + .HasColumnType("int"); + + b.Property("PartnerId") + .HasColumnType("int"); + + b.Property("SubscriptionId") + .HasColumnType("int"); + + b.Property("CreatedBy") + .HasColumnType("longtext"); + + b.Property("CreatedOn") + .HasColumnType("datetime(6)"); + + b.Property("ModifiedBy") + .HasColumnType("longtext"); + + b.Property("ModifiedOn") + .HasColumnType("datetime(6)"); + + b.HasKey("ApiGatewayId", "PartnerId", "SubscriptionId"); + + b.HasIndex("PartnerId"); + + b.HasIndex("SubscriptionId"); + + b.ToTable("ApiGatewayPartners", (string)null); + }); + + modelBuilder.Entity("SW.Bitween.Domain.Gateway.BusGateway", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("Id")); + + b.Property("CreatedBy") + .HasColumnType("longtext"); + + b.Property("CreatedOn") + .HasColumnType("datetime(6)"); + + b.Property("DocumentId") + .HasColumnType("int"); + + b.Property("Inactive") + .HasColumnType("tinyint(1)"); + + b.Property("ModifiedBy") + .HasColumnType("longtext"); + + b.Property("ModifiedOn") + .HasColumnType("datetime(6)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("varchar(200)"); + + b.HasKey("Id"); + + b.HasIndex("DocumentId"); + + b.ToTable("BusGateways", (string)null); + }); + + modelBuilder.Entity("SW.Bitween.Domain.Gateway.BusGatewayRoute", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("Id")); + + b.Property("BusGatewayId") + .HasColumnType("int"); + + b.Property("CreatedBy") + .HasColumnType("longtext"); + + b.Property("CreatedOn") + .HasColumnType("datetime(6)"); + + b.Property("MatchExpression") + .HasColumnType("longtext"); + + b.Property("ModifiedBy") + .HasColumnType("longtext"); + + b.Property("ModifiedOn") + .HasColumnType("datetime(6)"); + + b.Property("PartnerId") + .HasColumnType("int"); + + b.Property("SubscriptionId") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("BusGatewayId"); + + b.HasIndex("PartnerId"); + + b.HasIndex("SubscriptionId"); + + b.ToTable("BusGatewayRoutes", (string)null); + }); + + modelBuilder.Entity("SW.Bitween.Domain.GlobalAdapterValuesSet", b => + { + b.Property("Id") + .HasMaxLength(200) + .IsUnicode(false) + .HasColumnType("varchar(200)"); + + b.Property("Name") + .HasColumnType("longtext"); + + b.Property("Values") + .HasColumnType("longtext"); + + b.HasKey("Id"); + + b.ToTable("GlobalAdapterValuesSets", (string)null); + }); + + modelBuilder.Entity("SW.Bitween.Domain.Notifier", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("Id")); + + b.Property("HandlerId") + .HasMaxLength(200) + .IsUnicode(false) + .HasColumnType("varchar(200)"); + + b.Property("HandlerProperties") + .HasColumnType("longtext"); + + b.Property("Inactive") + .HasColumnType("tinyint(1)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("varchar(100)"); + + b.Property("RunOnBadResult") + .HasColumnType("tinyint(1)"); + + b.Property("RunOnFailedResult") + .HasColumnType("tinyint(1)"); + + b.Property("RunOnSubscriptions") + .HasColumnType("longtext"); + + b.Property("RunOnSuccessfulResult") + .HasColumnType("tinyint(1)"); + + b.HasKey("Id"); + + b.ToTable("Notifiers", (string)null); + }); + + modelBuilder.Entity("SW.Bitween.Domain.OnHoldXchange", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("Id")); + + b.Property("BadData") + .HasColumnType("tinyint(1)"); + + b.Property("Data") + .HasColumnType("longtext"); + + b.Property("FileName") + .HasColumnType("longtext"); + + b.Property("References") + .HasMaxLength(1024) + .HasColumnType("varchar(1024)"); + + b.Property("SubscriptionId") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("SubscriptionId"); + + b.ToTable("OnHoldXchanges", (string)null); + }); + + modelBuilder.Entity("SW.Bitween.Domain.Partner", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("Id")); + + b.Property("AdapterProperties") + .HasColumnType("longtext"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .IsUnicode(false) + .HasColumnType("varchar(200)"); + + b.HasKey("Id"); + + b.ToTable("Partners", (string)null); + + b.HasData( + new + { + Id = 1, + Name = "SYSTEM" + }); + }); + + modelBuilder.Entity("SW.Bitween.Domain.ReceiveAttempt", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("Id")); + + b.Property("ErrorMessage") + .HasMaxLength(4000) + .HasColumnType("varchar(4000)"); + + b.Property("ExchangeIds") + .HasColumnType("longtext"); + + b.Property("FinishedOn") + .HasColumnType("datetime(6)"); + + b.Property("Outcome") + .HasColumnType("int"); + + b.Property("StartedOn") + .HasColumnType("datetime(6)"); + + b.Property("SubscriptionId") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("SubscriptionId", "StartedOn"); + + b.ToTable("ReceiveAttempts", (string)null); + }); + + modelBuilder.Entity("SW.Bitween.Domain.RetryAlertOverride", b => + { + b.Property("SubscriptionId") + .HasColumnType("int"); + + b.Property("GroupId") + .HasColumnType("char(36)"); + + b.Property("AlertHandlerId") + .HasMaxLength(200) + .IsUnicode(false) + .HasColumnType("varchar(200)"); + + b.Property("AlertHandlerProperties") + .HasColumnType("longtext"); + + b.Property("AlertMode") + .HasColumnType("tinyint unsigned"); + + b.HasKey("SubscriptionId", "GroupId"); + + b.ToTable("RetryAlertOverrides", (string)null); + }); + + modelBuilder.Entity("SW.Bitween.Domain.RetryGroupUsage", b => + { + b.Property("SubscriptionId") + .HasColumnType("int"); + + b.Property("GroupId") + .HasColumnType("char(36)"); + + b.Property("AttemptsUsed") + .HasColumnType("int"); + + b.Property("ExhaustedNotifiedOn") + .HasColumnType("datetime(6)"); + + b.Property("LastAttemptOn") + .HasColumnType("datetime(6)"); + + b.HasKey("SubscriptionId", "GroupId"); + + b.ToTable("RetryGroupUsages", (string)null); + }); + + modelBuilder.Entity("SW.Bitween.Domain.RetryPolicy", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("Id")); + + b.Property("AlertHandlerId") + .HasMaxLength(200) + .IsUnicode(false) + .HasColumnType("varchar(200)"); + + b.Property("AlertHandlerProperties") + .HasColumnType("longtext"); + + b.Property("CreatedBy") + .HasColumnType("longtext"); + + b.Property("CreatedOn") + .HasColumnType("datetime(6)"); + + b.Property("Groups") + .HasColumnType("longtext"); + + b.Property("ModifiedBy") + .HasColumnType("longtext"); + + b.Property("ModifiedOn") + .HasColumnType("datetime(6)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("varchar(200)"); + + b.HasKey("Id"); + + b.ToTable("RetryPolicies", (string)null); + }); + + modelBuilder.Entity("SW.Bitween.Domain.Setting", b => + { + b.Property("Id") + .HasMaxLength(200) + .IsUnicode(false) + .HasColumnType("varchar(200)"); + + b.Property("CreatedBy") + .HasColumnType("longtext"); + + b.Property("CreatedOn") + .HasColumnType("datetime(6)"); + + b.Property("ModifiedBy") + .HasColumnType("longtext"); + + b.Property("ModifiedOn") + .HasColumnType("datetime(6)"); + + b.Property("Value") + .HasColumnType("longtext"); + + b.HasKey("Id"); + + b.ToTable("Settings", (string)null); + }); + + modelBuilder.Entity("SW.Bitween.Domain.Subscription", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("Id")); + + b.Property("AggregateOn") + .HasColumnType("datetime(6)"); + + b.Property("AggregationForId") + .HasColumnType("int"); + + b.Property("AggregationTarget") + .HasColumnType("tinyint unsigned"); + + b.Property("CategoryId") + .HasColumnType("int"); + + b.Property("ConsecutiveFailures") + .HasColumnType("int"); + + b.Property("CustomRetryPolicy") + .HasColumnType("longtext"); + + b.Property("DocumentFilter") + .HasColumnType("longtext"); + + b.Property("DocumentId") + .HasColumnType("int"); + + b.Property("HandlerId") + .HasMaxLength(200) + .IsUnicode(false) + .HasColumnType("varchar(200)"); + + b.Property("HandlerProperties") + .HasColumnType("longtext"); + + b.Property("Inactive") + .HasColumnType("tinyint(1)"); + + b.Property("IsRunning") + .HasColumnType("tinyint(1)"); + + b.Property("LastException") + .HasColumnType("longtext"); + + b.Property("MapperId") + .HasMaxLength(200) + .IsUnicode(false) + .HasColumnType("varchar(200)"); + + b.Property("MapperProperties") + .HasColumnType("longtext"); + + b.Property("MatchExpression") + .HasColumnType("longtext"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("varchar(100)"); + + b.Property("PartnerId") + .HasColumnType("int"); + + b.Property("PausedOn") + .HasColumnType("datetime(6)"); + + b.Property("ReceiveOn") + .HasColumnType("datetime(6)"); + + b.Property("ReceiverId") + .HasMaxLength(200) + .IsUnicode(false) + .HasColumnType("varchar(200)"); + + b.Property("ReceiverProperties") + .HasColumnType("longtext"); + + b.Property("ResponseMessageTypeName") + .HasMaxLength(500) + .IsUnicode(false) + .HasColumnType("varchar(500)"); + + b.Property("ResponseSubscriptionId") + .HasColumnType("int"); + + b.Property("RetryPolicyId") + .HasColumnType("int"); + + b.Property("Temporary") + .HasColumnType("tinyint(1)"); + + b.Property("Type") + .HasColumnType("tinyint unsigned"); + + b.Property("ValidatorId") + .HasMaxLength(200) + .IsUnicode(false) + .HasColumnType("varchar(200)"); + + b.Property("ValidatorProperties") + .HasColumnType("longtext"); + + b.Property("WorkGroupId") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("AggregationForId"); + + b.HasIndex("CategoryId"); + + b.HasIndex("DocumentId"); + + b.HasIndex("PartnerId"); + + b.HasIndex("ResponseSubscriptionId"); + + b.HasIndex("RetryPolicyId"); + + b.HasIndex("WorkGroupId"); + + b.ToTable("Subscriptions", (string)null); + }); + + modelBuilder.Entity("SW.Bitween.Domain.SubscriptionCategory", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("Id")); + + b.Property("Code") + .HasColumnType("varchar(255)"); + + b.Property("CreatedBy") + .HasColumnType("longtext"); + + b.Property("CreatedOn") + .HasColumnType("datetime(6)"); + + b.Property("Description") + .HasColumnType("longtext"); + + b.Property("ModifiedBy") + .HasColumnType("longtext"); + + b.Property("ModifiedOn") + .HasColumnType("datetime(6)"); + + b.HasKey("Id"); + + b.HasIndex("Code") + .IsUnique(); + + b.ToTable("SubscriptionCategory"); + }); + + modelBuilder.Entity("SW.Bitween.Domain.SubscriptionTrail", b => + { + b.Property("Id") + .HasMaxLength(50) + .HasColumnType("varchar(50)"); + + b.Property("Code") + .HasColumnType("int"); + + b.Property("CreatedBy") + .HasColumnType("longtext"); + + b.Property("CreatedOn") + .HasColumnType("datetime(6)"); + + b.Property("StateAfter") + .HasColumnType("longtext"); + + b.Property("StateBefore") + .HasColumnType("longtext"); + + b.Property("SubscriptionId") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("CreatedOn"); + + b.HasIndex("SubscriptionId"); + + b.ToTable("SubscriptionTrail"); + }); + + modelBuilder.Entity("SW.Bitween.Domain.WorkGroup", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("Id")); + + b.Property("BusMessageName") + .IsRequired() + .HasMaxLength(100) + .IsUnicode(false) + .HasColumnType("varchar(100)"); + + b.Property("Name") + .HasColumnType("longtext"); + + b.Property("Options") + .HasColumnType("longtext"); + + b.HasKey("Id"); + + b.ToTable("WorkGroup"); + }); + + modelBuilder.Entity("SW.Bitween.Domain.Xchange", b => + { + b.Property("Id") + .HasMaxLength(50) + .IsUnicode(false) + .HasColumnType("varchar(50)"); + + b.Property("CorrelationId") + .HasColumnType("longtext"); + + b.Property("DocumentId") + .HasColumnType("int"); + + b.Property("HandlerId") + .HasMaxLength(200) + .IsUnicode(false) + .HasColumnType("varchar(200)"); + + b.Property("HandlerProperties") + .HasColumnType("longtext"); + + b.Property("InputContentType") + .HasMaxLength(200) + .IsUnicode(false) + .HasColumnType("varchar(200)"); + + b.Property("InputHash") + .IsRequired() + .HasMaxLength(50) + .IsUnicode(false) + .HasColumnType("varchar(50)"); + + b.Property("InputName") + .HasMaxLength(200) + .HasColumnType("varchar(200)"); + + b.Property("InputSize") + .HasColumnType("int"); + + b.Property("ManualRetry") + .HasColumnType("tinyint(1)"); + + b.Property("MapperId") + .HasMaxLength(200) + .IsUnicode(false) + .HasColumnType("varchar(200)"); + + b.Property("MapperProperties") + .HasColumnType("longtext"); + + b.Property("PartnerId") + .HasColumnType("int"); + + b.Property("References") + .HasMaxLength(1024) + .HasColumnType("varchar(1024)"); + + b.Property("ResponseMessageTypeName") + .HasMaxLength(500) + .IsUnicode(false) + .HasColumnType("varchar(500)"); + + b.Property("ResponseSubscriptionId") + .HasColumnType("int"); + + b.Property("RetryFor") + .HasMaxLength(50) + .IsUnicode(false) + .HasColumnType("varchar(50)"); + + b.Property("StartedOn") + .HasColumnType("datetime(6)"); + + b.Property("SubscriptionId") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("DocumentId"); + + b.HasIndex("InputHash"); + + b.HasIndex("RetryFor"); + + b.HasIndex("StartedOn"); + + b.HasIndex("SubscriptionId"); + + b.ToTable("Xchanges", (string)null); + }); + + modelBuilder.Entity("SW.Bitween.Domain.XchangeAggregation", b => + { + b.Property("Id") + .HasMaxLength(50) + .IsUnicode(false) + .HasColumnType("varchar(50)"); + + b.Property("AggregatedOn") + .HasColumnType("datetime(6)"); + + b.Property("AggregationXchangeId") + .IsRequired() + .HasMaxLength(50) + .IsUnicode(false) + .HasColumnType("varchar(50)"); + + b.HasKey("Id"); + + b.HasIndex("AggregationXchangeId"); + + b.ToTable("XchangeAggregations", (string)null); + }); + + modelBuilder.Entity("SW.Bitween.Domain.XchangeDelivery", b => + { + b.Property("Id") + .HasMaxLength(50) + .IsUnicode(false) + .HasColumnType("varchar(50)"); + + b.Property("DeliveredOn") + .HasColumnType("datetime(6)"); + + b.HasKey("Id"); + + b.HasIndex("DeliveredOn"); + + b.ToTable("XchangeDeliveries", (string)null); + }); + + modelBuilder.Entity("SW.Bitween.Domain.XchangeNotification", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("Id")); + + b.Property("Exception") + .HasColumnType("longtext"); + + b.Property("FinishedOn") + .HasColumnType("datetime(6)"); + + b.Property("NotifierId") + .HasColumnType("int"); + + b.Property("NotifierName") + .HasColumnType("longtext"); + + b.Property("Success") + .HasColumnType("tinyint(1)"); + + b.Property("XchangeId") + .HasMaxLength(50) + .IsUnicode(false) + .HasColumnType("varchar(50)"); + + b.HasKey("Id"); + + b.ToTable("XchangeNotifications", (string)null); + }); + + modelBuilder.Entity("SW.Bitween.Domain.XchangePromotedProperties", b => + { + b.Property("Id") + .HasMaxLength(50) + .IsUnicode(false) + .HasColumnType("varchar(50)"); + + b.Property("Hits") + .HasMaxLength(2000) + .IsUnicode(false) + .HasColumnType("varchar(2000)"); + + b.Property("Properties") + .HasColumnType("longtext"); + + b.Property("PropertiesRaw") + .HasColumnType("varchar(255)"); + + b.HasKey("Id"); + + b.HasIndex("PropertiesRaw"); + + b.ToTable("XchangePromotedProperties", (string)null); + }); + + modelBuilder.Entity("SW.Bitween.Domain.XchangeResult", b => + { + b.Property("Id") + .HasMaxLength(50) + .IsUnicode(false) + .HasColumnType("varchar(50)"); + + b.Property("AttemptNumber") + .HasColumnType("int"); + + b.Property("Exception") + .HasColumnType("longtext"); + + b.Property("FinishedOn") + .HasColumnType("datetime(6)"); + + b.Property("OutputBad") + .HasColumnType("tinyint(1)"); + + b.Property("OutputContentType") + .HasMaxLength(200) + .IsUnicode(false) + .HasColumnType("varchar(200)"); + + b.Property("OutputHash") + .HasMaxLength(50) + .IsUnicode(false) + .HasColumnType("varchar(50)"); + + b.Property("OutputName") + .HasMaxLength(200) + .HasColumnType("varchar(200)"); + + b.Property("OutputSize") + .HasColumnType("int"); + + b.Property("ResponseBad") + .HasColumnType("tinyint(1)"); + + b.Property("ResponseContentType") + .HasMaxLength(200) + .IsUnicode(false) + .HasColumnType("varchar(200)"); + + b.Property("ResponseHash") + .HasMaxLength(50) + .IsUnicode(false) + .HasColumnType("varchar(50)"); + + b.Property("ResponseName") + .HasMaxLength(200) + .HasColumnType("varchar(200)"); + + b.Property("ResponseSize") + .HasColumnType("int"); + + b.Property("ResponseXchangeId") + .HasColumnType("longtext"); + + b.Property("RetryBlockedReason") + .HasMaxLength(500) + .HasColumnType("varchar(500)"); + + b.Property("RetryGroupId") + .HasColumnType("char(36)"); + + b.Property("Success") + .HasColumnType("tinyint(1)"); + + b.HasKey("Id"); + + b.HasIndex("RetryGroupId"); + + b.ToTable("XchangeResults", (string)null); + }); + + modelBuilder.Entity("SW.Bitween.RunFlagUpdater+RunningResult", b => + { + b.Property("IsRunning") + .HasColumnType("tinyint(1)"); + + b.ToTable((string)null); + + b.ToView(null, (string)null); + }); + + modelBuilder.Entity("SW.Scheduler.JobExecution", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint") + .HasColumnName("id"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("Id")); + + b.Property("Context") + .HasColumnType("longtext") + .HasColumnName("context"); + + b.Property("DurationMs") + .HasColumnType("bigint") + .HasColumnName("duration_ms"); + + b.Property("EndTimeUtc") + .HasColumnType("datetime(6)") + .HasColumnName("end_time_utc"); + + b.Property("Error") + .HasColumnType("longtext") + .HasColumnName("error"); + + b.Property("FireInstanceId") + .IsRequired() + .HasColumnType("varchar(200)") + .HasColumnName("fire_instance_id"); + + b.Property("JobGroup") + .IsRequired() + .HasColumnType("varchar(200)") + .HasColumnName("job_group"); + + b.Property("JobName") + .IsRequired() + .HasColumnType("varchar(200)") + .HasColumnName("job_name"); + + b.Property("JobTypeName") + .IsRequired() + .HasColumnType("varchar(200)") + .HasColumnName("job_type_name"); + + b.Property("Node") + .IsRequired() + .HasColumnType("varchar(200)") + .HasColumnName("node"); + + b.Property("StartTimeUtc") + .HasColumnType("datetime(6)") + .HasColumnName("start_time_utc"); + + b.Property("Success") + .HasColumnType("tinyint(1)") + .HasColumnName("success"); + + b.HasKey("Id"); + + b.HasIndex("FireInstanceId") + .IsUnique() + .HasDatabaseName("idx_je_fire_instance_id"); + + b.HasIndex("StartTimeUtc") + .HasDatabaseName("idx_je_start_time"); + + b.HasIndex("Success") + .HasDatabaseName("idx_je_success"); + + b.HasIndex("JobGroup", "JobName", "StartTimeUtc") + .HasDatabaseName("idx_je_group_name_start"); + + b.ToTable("job_executions", (string)null); + }); + + modelBuilder.Entity("SW.Scheduler.QuartzBlobTrigger", b => + { + b.Property("SchedulerName") + .HasColumnType("varchar(200)") + .HasColumnName("sched_name"); + + b.Property("TriggerName") + .HasColumnType("varchar(200)") + .HasColumnName("trigger_name"); + + b.Property("TriggerGroup") + .HasColumnType("varchar(200)") + .HasColumnName("trigger_group"); + + b.Property("BlobData") + .HasColumnType("longblob") + .HasColumnName("blob_data"); + + b.HasKey("SchedulerName", "TriggerName", "TriggerGroup"); + + b.ToTable("QRTZ_blob_triggers", (string)null); + }); + + modelBuilder.Entity("SW.Scheduler.QuartzCalendar", b => + { + b.Property("SchedulerName") + .HasColumnType("varchar(200)") + .HasColumnName("sched_name"); + + b.Property("CalendarName") + .HasColumnType("varchar(200)") + .HasColumnName("calendar_name"); + + b.Property("Calendar") + .IsRequired() + .HasColumnType("longblob") + .HasColumnName("calendar"); + + b.HasKey("SchedulerName", "CalendarName"); + + b.ToTable("QRTZ_calendars", (string)null); + }); + + modelBuilder.Entity("SW.Scheduler.QuartzCronTrigger", b => + { + b.Property("SchedulerName") + .HasColumnType("varchar(200)") + .HasColumnName("sched_name"); + + b.Property("TriggerName") + .HasColumnType("varchar(200)") + .HasColumnName("trigger_name"); + + b.Property("TriggerGroup") + .HasColumnType("varchar(200)") + .HasColumnName("trigger_group"); + + b.Property("CronExpression") + .IsRequired() + .HasColumnType("varchar(200)") + .HasColumnName("cron_expression"); + + b.Property("TimeZoneId") + .HasColumnType("varchar(200)") + .HasColumnName("time_zone_id"); + + b.HasKey("SchedulerName", "TriggerName", "TriggerGroup"); + + b.ToTable("QRTZ_cron_triggers", (string)null); + }); + + modelBuilder.Entity("SW.Scheduler.QuartzFiredTrigger", b => + { + b.Property("SchedulerName") + .HasColumnType("varchar(200)") + .HasColumnName("sched_name"); + + b.Property("EntryId") + .HasColumnType("varchar(200)") + .HasColumnName("entry_id"); + + b.Property("FiredTime") + .HasColumnType("bigint") + .HasColumnName("fired_time"); + + b.Property("InstanceName") + .IsRequired() + .HasColumnType("varchar(200)") + .HasColumnName("instance_name"); + + b.Property("IsNonConcurrent") + .HasColumnType("tinyint(1)") + .HasColumnName("is_nonconcurrent"); + + b.Property("JobGroup") + .HasColumnType("varchar(200)") + .HasColumnName("job_group"); + + b.Property("JobName") + .HasColumnType("varchar(200)") + .HasColumnName("job_name"); + + b.Property("Priority") + .HasColumnType("int") + .HasColumnName("priority"); + + b.Property("RequestsRecovery") + .HasColumnType("tinyint(1)") + .HasColumnName("requests_recovery"); + + b.Property("ScheduledTime") + .HasColumnType("bigint") + .HasColumnName("sched_time"); + + b.Property("State") + .IsRequired() + .HasColumnType("varchar(200)") + .HasColumnName("state"); + + b.Property("TriggerGroup") + .IsRequired() + .HasColumnType("varchar(200)") + .HasColumnName("trigger_group"); + + b.Property("TriggerName") + .IsRequired() + .HasColumnType("varchar(200)") + .HasColumnName("trigger_name"); + + b.HasKey("SchedulerName", "EntryId"); + + b.HasIndex("InstanceName") + .HasDatabaseName("idx_QRTZ_ft_trig_inst_name"); + + b.HasIndex("JobGroup") + .HasDatabaseName("idx_QRTZ_ft_job_group"); + + b.HasIndex("JobName") + .HasDatabaseName("idx_QRTZ_ft_job_name"); + + b.HasIndex("RequestsRecovery") + .HasDatabaseName("idx_QRTZ_ft_job_req_recovery"); + + b.HasIndex("TriggerGroup") + .HasDatabaseName("idx_QRTZ_ft_trig_group"); + + b.HasIndex("TriggerName") + .HasDatabaseName("idx_QRTZ_ft_trig_name"); + + b.HasIndex("SchedulerName", "TriggerName", "TriggerGroup") + .HasDatabaseName("idx_QRTZ_ft_trig_nm_gp"); + + b.ToTable("QRTZ_fired_triggers", (string)null); + }); + + modelBuilder.Entity("SW.Scheduler.QuartzJobDetail", b => + { + b.Property("SchedulerName") + .HasColumnType("varchar(200)") + .HasColumnName("sched_name"); + + b.Property("JobName") + .HasColumnType("varchar(200)") + .HasColumnName("job_name"); + + b.Property("JobGroup") + .HasColumnType("varchar(200)") + .HasColumnName("job_group"); + + b.Property("Description") + .HasColumnType("varchar(200)") + .HasColumnName("description"); + + b.Property("IsDurable") + .HasColumnType("tinyint(1)") + .HasColumnName("is_durable"); + + b.Property("IsNonConcurrent") + .HasColumnType("tinyint(1)") + .HasColumnName("is_nonconcurrent"); + + b.Property("IsUpdateData") + .HasColumnType("tinyint(1)") + .HasColumnName("is_update_data"); + + b.Property("JobClassName") + .IsRequired() + .HasColumnType("varchar(200)") + .HasColumnName("job_class_name"); + + b.Property("JobData") + .HasColumnType("longblob") + .HasColumnName("job_data"); + + b.Property("RequestsRecovery") + .HasColumnType("tinyint(1)") + .HasColumnName("requests_recovery"); + + b.HasKey("SchedulerName", "JobName", "JobGroup"); + + b.HasIndex("RequestsRecovery") + .HasDatabaseName("idx_j_req_recovery"); + + b.ToTable("QRTZ_job_details", (string)null); + }); + + modelBuilder.Entity("SW.Scheduler.QuartzLock", b => + { + b.Property("SchedulerName") + .HasColumnType("varchar(200)") + .HasColumnName("sched_name"); + + b.Property("LockName") + .HasColumnType("varchar(200)") + .HasColumnName("lock_name"); + + b.HasKey("SchedulerName", "LockName"); + + b.ToTable("QRTZ_locks", (string)null); + }); + + modelBuilder.Entity("SW.Scheduler.QuartzPausedTriggerGroup", b => + { + b.Property("SchedulerName") + .HasColumnType("varchar(200)") + .HasColumnName("sched_name"); + + b.Property("TriggerGroup") + .HasColumnType("varchar(200)") + .HasColumnName("trigger_group"); + + b.HasKey("SchedulerName", "TriggerGroup"); + + b.ToTable("QRTZ_paused_trigger_grps", (string)null); + }); + + modelBuilder.Entity("SW.Scheduler.QuartzSchedulerState", b => + { + b.Property("SchedulerName") + .HasColumnType("varchar(200)") + .HasColumnName("sched_name"); + + b.Property("InstanceName") + .HasColumnType("varchar(200)") + .HasColumnName("instance_name"); + + b.Property("CheckInInterval") + .HasColumnType("bigint") + .HasColumnName("checkin_interval"); + + b.Property("LastCheckInTime") + .HasColumnType("bigint") + .HasColumnName("last_checkin_time"); + + b.HasKey("SchedulerName", "InstanceName"); + + b.ToTable("QRTZ_scheduler_state", (string)null); + }); + + modelBuilder.Entity("SW.Scheduler.QuartzSimplePropertyTrigger", b => + { + b.Property("SchedulerName") + .HasColumnType("varchar(200)") + .HasColumnName("sched_name"); + + b.Property("TriggerName") + .HasColumnType("varchar(200)") + .HasColumnName("trigger_name"); + + b.Property("TriggerGroup") + .HasColumnType("varchar(200)") + .HasColumnName("trigger_group"); + + b.Property("BooleanProperty1") + .HasColumnType("tinyint(1)") + .HasColumnName("bool_prop_1"); + + b.Property("BooleanProperty2") + .HasColumnType("tinyint(1)") + .HasColumnName("bool_prop_2"); + + b.Property("DecimalProperty1") + .HasColumnType("numeric") + .HasColumnName("dec_prop_1"); + + b.Property("DecimalProperty2") + .HasColumnType("numeric") + .HasColumnName("dec_prop_2"); + + b.Property("IntegerProperty1") + .HasColumnType("int") + .HasColumnName("int_prop_1"); + + b.Property("IntegerProperty2") + .HasColumnType("int") + .HasColumnName("int_prop_2"); + + b.Property("LongProperty1") + .HasColumnType("bigint") + .HasColumnName("long_prop_1"); + + b.Property("LongProperty2") + .HasColumnType("bigint") + .HasColumnName("long_prop_2"); + + b.Property("StringProperty1") + .HasColumnType("varchar(200)") + .HasColumnName("str_prop_1"); + + b.Property("StringProperty2") + .HasColumnType("varchar(200)") + .HasColumnName("str_prop_2"); + + b.Property("StringProperty3") + .HasColumnType("varchar(200)") + .HasColumnName("str_prop_3"); + + b.Property("TimeZoneId") + .HasColumnType("varchar(200)") + .HasColumnName("time_zone_id"); + + b.HasKey("SchedulerName", "TriggerName", "TriggerGroup"); + + b.ToTable("QRTZ_simprop_triggers", (string)null); + }); + + modelBuilder.Entity("SW.Scheduler.QuartzSimpleTrigger", b => + { + b.Property("SchedulerName") + .HasColumnType("varchar(200)") + .HasColumnName("sched_name"); + + b.Property("TriggerName") + .HasColumnType("varchar(200)") + .HasColumnName("trigger_name"); + + b.Property("TriggerGroup") + .HasColumnType("varchar(200)") + .HasColumnName("trigger_group"); + + b.Property("RepeatCount") + .HasColumnType("bigint") + .HasColumnName("repeat_count"); + + b.Property("RepeatInterval") + .HasColumnType("bigint") + .HasColumnName("repeat_interval"); + + b.Property("TimesTriggered") + .HasColumnType("bigint") + .HasColumnName("times_triggered"); + + b.HasKey("SchedulerName", "TriggerName", "TriggerGroup"); + + b.ToTable("QRTZ_simple_triggers", (string)null); + }); + + modelBuilder.Entity("SW.Scheduler.QuartzTrigger", b => + { + b.Property("SchedulerName") + .HasColumnType("varchar(200)") + .HasColumnName("sched_name"); + + b.Property("TriggerName") + .HasColumnType("varchar(200)") + .HasColumnName("trigger_name"); + + b.Property("TriggerGroup") + .HasColumnType("varchar(200)") + .HasColumnName("trigger_group"); + + b.Property("CalendarName") + .HasColumnType("varchar(200)") + .HasColumnName("calendar_name"); + + b.Property("Description") + .HasColumnType("varchar(200)") + .HasColumnName("description"); + + b.Property("EndTime") + .HasColumnType("bigint") + .HasColumnName("end_time"); + + b.Property("JobData") + .HasColumnType("longblob") + .HasColumnName("job_data"); + + b.Property("JobGroup") + .IsRequired() + .HasColumnType("varchar(200)") + .HasColumnName("job_group"); + + b.Property("JobName") + .IsRequired() + .HasColumnType("varchar(200)") + .HasColumnName("job_name"); + + b.Property("MisfireInstruction") + .HasColumnType("int") + .HasColumnName("misfire_instr"); + + b.Property("NextFireTime") + .HasColumnType("bigint") + .HasColumnName("next_fire_time"); + + b.Property("PreviousFireTime") + .HasColumnType("bigint") + .HasColumnName("prev_fire_time"); + + b.Property("Priority") + .HasColumnType("int") + .HasColumnName("priority"); + + b.Property("StartTime") + .HasColumnType("bigint") + .HasColumnName("start_time"); + + b.Property("TriggerState") + .IsRequired() + .HasColumnType("varchar(200)") + .HasColumnName("trigger_state"); + + b.Property("TriggerType") + .IsRequired() + .HasColumnType("varchar(200)") + .HasColumnName("trigger_type"); + + b.HasKey("SchedulerName", "TriggerName", "TriggerGroup"); + + b.HasIndex("NextFireTime") + .HasDatabaseName("idx_t_next_fire_time"); + + b.HasIndex("TriggerState") + .HasDatabaseName("idx_t_state"); + + b.HasIndex("NextFireTime", "TriggerState") + .HasDatabaseName("idx_t_nft_st"); + + b.HasIndex("SchedulerName", "JobName", "JobGroup"); + + b.ToTable("QRTZ_triggers", (string)null); + }); + + modelBuilder.Entity("SW.Bitween.Domain.Accounts.AccountRoleLink", b => + { + b.HasOne("SW.Bitween.Domain.Accounts.Account", null) + .WithMany() + .HasForeignKey("AccountId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("SW.Bitween.Domain.Accounts.Role", null) + .WithMany() + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("SW.Bitween.Domain.Accounts.RefreshToken", b => + { + b.HasOne("SW.Bitween.Domain.Accounts.Account", null) + .WithMany() + .HasForeignKey("AccountId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("SW.Bitween.Domain.DocumentTrail", b => + { + b.HasOne("SW.Bitween.Domain.Document", "Document") + .WithMany() + .HasForeignKey("DocumentId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Document"); + }); + + modelBuilder.Entity("SW.Bitween.Domain.Gateway.ApiGatewayPartner", b => + { + b.HasOne("SW.Bitween.Domain.Gateway.ApiGateway", "ApiGateway") + .WithMany("Partners") + .HasForeignKey("ApiGatewayId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("SW.Bitween.Domain.Partner", "Partner") + .WithMany() + .HasForeignKey("PartnerId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("SW.Bitween.Domain.Subscription", "Subscription") + .WithMany() + .HasForeignKey("SubscriptionId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("ApiGateway"); + + b.Navigation("Partner"); + + b.Navigation("Subscription"); + }); + + modelBuilder.Entity("SW.Bitween.Domain.Gateway.BusGateway", b => + { + b.HasOne("SW.Bitween.Domain.Document", null) + .WithMany() + .HasForeignKey("DocumentId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("SW.Bitween.Domain.Gateway.BusGatewayRoute", b => + { + b.HasOne("SW.Bitween.Domain.Gateway.BusGateway", "BusGateway") + .WithMany("Routes") + .HasForeignKey("BusGatewayId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("SW.Bitween.Domain.Partner", "Partner") + .WithMany() + .HasForeignKey("PartnerId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("SW.Bitween.Domain.Subscription", "Subscription") + .WithMany() + .HasForeignKey("SubscriptionId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("BusGateway"); + + b.Navigation("Partner"); + + b.Navigation("Subscription"); + }); + + modelBuilder.Entity("SW.Bitween.Domain.Partner", b => + { + b.OwnsMany("SW.Bitween.Domain.ApiCredential", "ApiCredentials", b1 => + { + b1.Property("PartnerId") + .HasColumnType("int"); + + b1.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b1.Property("Id")); + + b1.Property("Key") + .IsRequired() + .HasMaxLength(500) + .IsUnicode(false) + .HasColumnType("varchar(500)"); + + b1.Property("Name") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("varchar(500)"); + + b1.HasKey("PartnerId", "Id"); + + b1.HasIndex("Key") + .IsUnique(); + + b1.ToTable("PartnerApiCredentials", (string)null); + + b1.WithOwner() + .HasForeignKey("PartnerId"); + + b1.HasData( + new + { + PartnerId = 1, + Id = 1, + Key = "7facc758283844b49cc4ffd26a75b1de", + Name = "default" + }); + }); + + b.Navigation("ApiCredentials"); + }); + + modelBuilder.Entity("SW.Bitween.Domain.Subscription", b => + { + b.HasOne("SW.Bitween.Domain.Subscription", null) + .WithMany() + .HasForeignKey("AggregationForId") + .OnDelete(DeleteBehavior.Restrict) + .HasConstraintName("FK_Subscriptions_AggFor"); + + b.HasOne("SW.Bitween.Domain.SubscriptionCategory", "Category") + .WithMany() + .HasForeignKey("CategoryId"); + + b.HasOne("SW.Bitween.Domain.Document", null) + .WithMany() + .HasForeignKey("DocumentId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("SW.Bitween.Domain.Partner", null) + .WithMany("Subscriptions") + .HasForeignKey("PartnerId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("SW.Bitween.Domain.Subscription", null) + .WithMany() + .HasForeignKey("ResponseSubscriptionId") + .OnDelete(DeleteBehavior.Restrict) + .HasConstraintName("FK_Subscriptions_RespSub"); + + b.HasOne("SW.Bitween.Domain.RetryPolicy", "RetryPolicy") + .WithMany() + .HasForeignKey("RetryPolicyId") + .OnDelete(DeleteBehavior.SetNull); + + b.HasOne("SW.Bitween.Domain.WorkGroup", "WorkGroup") + .WithMany() + .HasForeignKey("WorkGroupId"); + + b.OwnsMany("SW.Bitween.Domain.Schedule", "Schedules", b1 => + { + b1.Property("SubscriptionId") + .HasColumnType("int"); + + b1.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b1.Property("Id")); + + b1.Property("Backwards") + .HasColumnType("tinyint(1)"); + + b1.Property("On") + .HasColumnType("bigint"); + + b1.Property("Recurrence") + .HasColumnType("tinyint unsigned"); + + b1.HasKey("SubscriptionId", "Id"); + + b1.ToTable("SubscriptionSchedules", (string)null); + + b1.WithOwner() + .HasForeignKey("SubscriptionId"); + }); + + b.Navigation("Category"); + + b.Navigation("RetryPolicy"); + + b.Navigation("Schedules"); + + b.Navigation("WorkGroup"); + }); + + modelBuilder.Entity("SW.Bitween.Domain.SubscriptionTrail", b => + { + b.HasOne("SW.Bitween.Domain.Subscription", "Subscription") + .WithMany() + .HasForeignKey("SubscriptionId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Subscription"); + }); + + modelBuilder.Entity("SW.Bitween.Domain.Xchange", b => + { + b.HasOne("SW.Bitween.Domain.Document", null) + .WithMany() + .HasForeignKey("DocumentId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("SW.Bitween.Domain.XchangeAggregation", b => + { + b.HasOne("SW.Bitween.Domain.Xchange", null) + .WithOne() + .HasForeignKey("SW.Bitween.Domain.XchangeAggregation", "Id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("SW.Bitween.Domain.XchangeDelivery", b => + { + b.HasOne("SW.Bitween.Domain.Xchange", null) + .WithOne() + .HasForeignKey("SW.Bitween.Domain.XchangeDelivery", "Id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("SW.Bitween.Domain.XchangePromotedProperties", b => + { + b.HasOne("SW.Bitween.Domain.Xchange", null) + .WithOne() + .HasForeignKey("SW.Bitween.Domain.XchangePromotedProperties", "Id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("SW.Bitween.Domain.XchangeResult", b => + { + b.HasOne("SW.Bitween.Domain.Xchange", null) + .WithOne() + .HasForeignKey("SW.Bitween.Domain.XchangeResult", "Id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("SW.Scheduler.QuartzBlobTrigger", b => + { + b.HasOne("SW.Scheduler.QuartzTrigger", "Trigger") + .WithMany("BlobTriggers") + .HasForeignKey("SchedulerName", "TriggerName", "TriggerGroup") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Trigger"); + }); + + modelBuilder.Entity("SW.Scheduler.QuartzCronTrigger", b => + { + b.HasOne("SW.Scheduler.QuartzTrigger", "Trigger") + .WithMany("CronTriggers") + .HasForeignKey("SchedulerName", "TriggerName", "TriggerGroup") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Trigger"); + }); + + modelBuilder.Entity("SW.Scheduler.QuartzSimplePropertyTrigger", b => + { + b.HasOne("SW.Scheduler.QuartzTrigger", "Trigger") + .WithMany("SimplePropertyTriggers") + .HasForeignKey("SchedulerName", "TriggerName", "TriggerGroup") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Trigger"); + }); + + modelBuilder.Entity("SW.Scheduler.QuartzSimpleTrigger", b => + { + b.HasOne("SW.Scheduler.QuartzTrigger", "Trigger") + .WithMany("SimpleTriggers") + .HasForeignKey("SchedulerName", "TriggerName", "TriggerGroup") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Trigger"); + }); + + modelBuilder.Entity("SW.Scheduler.QuartzTrigger", b => + { + b.HasOne("SW.Scheduler.QuartzJobDetail", "JobDetail") + .WithMany("Triggers") + .HasForeignKey("SchedulerName", "JobName", "JobGroup") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("JobDetail"); + }); + + modelBuilder.Entity("SW.Bitween.Domain.Gateway.ApiGateway", b => + { + b.Navigation("Partners"); + }); + + modelBuilder.Entity("SW.Bitween.Domain.Gateway.BusGateway", b => + { + b.Navigation("Routes"); + }); + + modelBuilder.Entity("SW.Bitween.Domain.Partner", b => + { + b.Navigation("Subscriptions"); + }); + + modelBuilder.Entity("SW.Scheduler.QuartzJobDetail", b => + { + b.Navigation("Triggers"); + }); + + modelBuilder.Entity("SW.Scheduler.QuartzTrigger", b => + { + b.Navigation("BlobTriggers"); + + b.Navigation("CronTriggers"); + + b.Navigation("SimplePropertyTriggers"); + + b.Navigation("SimpleTriggers"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/SW.Bitween.MySql/Migrations/20260826101534_FixDefaultCreatedOnTimezone.cs b/SW.Bitween.MySql/Migrations/20260826101534_FixDefaultCreatedOnTimezone.cs new file mode 100644 index 00000000..9c659157 --- /dev/null +++ b/SW.Bitween.MySql/Migrations/20260826101534_FixDefaultCreatedOnTimezone.cs @@ -0,0 +1,75 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace SW.Bitween.MySql.Migrations +{ + /// + public partial class FixDefaultCreatedOnTimezone : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.UpdateData( + table: "Accounts", + keyColumn: "Id", + keyValue: 9999, + column: "CreatedOn", + value: new DateTime(2022, 1, 1, 0, 0, 0, 0, DateTimeKind.Utc)); + + migrationBuilder.UpdateData( + table: "Roles", + keyColumn: "Id", + keyValue: 1, + column: "CreatedOn", + value: new DateTime(2022, 1, 1, 0, 0, 0, 0, DateTimeKind.Utc)); + + migrationBuilder.UpdateData( + table: "Roles", + keyColumn: "Id", + keyValue: 2, + column: "CreatedOn", + value: new DateTime(2022, 1, 1, 0, 0, 0, 0, DateTimeKind.Utc)); + + migrationBuilder.UpdateData( + table: "Roles", + keyColumn: "Id", + keyValue: 3, + column: "CreatedOn", + value: new DateTime(2022, 1, 1, 0, 0, 0, 0, DateTimeKind.Utc)); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.UpdateData( + table: "Accounts", + keyColumn: "Id", + keyValue: 9999, + column: "CreatedOn", + value: new DateTime(2021, 12, 31, 22, 0, 0, 0, DateTimeKind.Utc)); + + migrationBuilder.UpdateData( + table: "Roles", + keyColumn: "Id", + keyValue: 1, + column: "CreatedOn", + value: new DateTime(2021, 12, 31, 22, 0, 0, 0, DateTimeKind.Utc)); + + migrationBuilder.UpdateData( + table: "Roles", + keyColumn: "Id", + keyValue: 2, + column: "CreatedOn", + value: new DateTime(2021, 12, 31, 22, 0, 0, 0, DateTimeKind.Utc)); + + migrationBuilder.UpdateData( + table: "Roles", + keyColumn: "Id", + keyValue: 3, + column: "CreatedOn", + value: new DateTime(2021, 12, 31, 22, 0, 0, 0, DateTimeKind.Utc)); + } + } +} diff --git a/SW.Bitween.MySql/Migrations/BitweenDbContextModelSnapshot.cs b/SW.Bitween.MySql/Migrations/BitweenDbContextModelSnapshot.cs index 78a9a898..113a9d3d 100644 --- a/SW.Bitween.MySql/Migrations/BitweenDbContextModelSnapshot.cs +++ b/SW.Bitween.MySql/Migrations/BitweenDbContextModelSnapshot.cs @@ -17,7 +17,7 @@ protected override void BuildModel(ModelBuilder modelBuilder) { #pragma warning disable 612, 618 modelBuilder - .HasAnnotation("ProductVersion", "8.0.23") + .HasAnnotation("ProductVersion", "9.0.19") .HasAnnotation("Relational:MaxIdentifierLength", 64); MySqlModelBuilderExtensions.AutoIncrementColumns(modelBuilder); @@ -89,7 +89,7 @@ protected override void BuildModel(ModelBuilder modelBuilder) new { Id = 9999, - CreatedOn = new DateTime(2021, 12, 31, 22, 0, 0, 0, DateTimeKind.Utc), + CreatedOn = new DateTime(2022, 1, 1, 0, 0, 0, 0, DateTimeKind.Utc), Deleted = false, Disabled = false, DisplayName = "Admin", @@ -186,7 +186,7 @@ protected override void BuildModel(ModelBuilder modelBuilder) new { Id = 1, - CreatedOn = new DateTime(2021, 12, 31, 22, 0, 0, 0, DateTimeKind.Utc), + CreatedOn = new DateTime(2022, 1, 1, 0, 0, 0, 0, DateTimeKind.Utc), Description = "Full access to everything, including members, roles and settings.", IsSystem = true, Name = "Administrator", @@ -195,7 +195,7 @@ protected override void BuildModel(ModelBuilder modelBuilder) new { Id = 2, - CreatedOn = new DateTime(2021, 12, 31, 22, 0, 0, 0, DateTimeKind.Utc), + CreatedOn = new DateTime(2022, 1, 1, 0, 0, 0, 0, DateTimeKind.Utc), Description = "Runs and configures integrations. Can't manage members, roles or settings.", IsSystem = true, Name = "Member", @@ -204,7 +204,7 @@ protected override void BuildModel(ModelBuilder modelBuilder) new { Id = 3, - CreatedOn = new DateTime(2021, 12, 31, 22, 0, 0, 0, DateTimeKind.Utc), + CreatedOn = new DateTime(2022, 1, 1, 0, 0, 0, 0, DateTimeKind.Utc), Description = "Read-only access to integrations, exchanges and configuration.", IsSystem = true, Name = "Viewer", diff --git a/SW.Bitween.UnitTests/AccountCreateValidatorTests.cs b/SW.Bitween.UnitTests/AccountCreateValidatorTests.cs new file mode 100644 index 00000000..d0960c9e --- /dev/null +++ b/SW.Bitween.UnitTests/AccountCreateValidatorTests.cs @@ -0,0 +1,104 @@ +using System.Linq; +using System.Reflection; +using FluentValidation; +using Microsoft.VisualStudio.TestTools.UnitTesting; +using SW.Bitween.Model; + +namespace SW.Bitween.UnitTests; + +/// +/// The rules that decide whether a request to add a team member is accepted at all. +/// +/// +/// These validators are nested private classes discovered by the request pipeline, so a test that +/// calls the handler directly never runs them — which is exactly how the handler and its validator +/// came to disagree. The handler was updated to take explicit role ids and treat the coarse +/// Role as legacy, but the validator went on demanding Role unconditionally, so the +/// payload the UI actually sends was rejected and adding a member failed outright. +/// +[TestClass] +public class AccountCreateValidatorTests +{ + /// + /// Builds the handler's nested validator. Reflection because it is private by design — the + /// pipeline finds it by scanning, and nothing else is meant to construct one. + /// + private static IValidator Validator(bool disableEmailPasswordLogin = false) + { + var type = typeof(Resources.Accounts.Create) + .GetNestedType("Validate", BindingFlags.NonPublic)!; + return (IValidator)System.Activator.CreateInstance( + type, new BitweenOptions { DisableEmailPasswordLogin = disableEmailPasswordLogin })!; + } + + private static CreateAccountModel Valid() => new() + { + Name = "New Member", + Email = "new-member@test.local", + Password = "A-Strong-Passw0rd!", + }; + + private static string[] FailedFields(CreateAccountModel model) => + Validator().Validate(model).Errors.Select(e => e.PropertyName).ToArray(); + + [TestMethod] + public void Explicit_role_ids_alone_are_accepted() + { + var model = Valid(); + model.RoleIds = [Domain.Accounts.Role.MemberId]; + + // Exactly what the UI posts. Demanding the legacy Role here is what broke adding a member. + Assert.IsTrue(Validator().Validate(model).IsValid); + } + + [TestMethod] + public void The_legacy_coarse_role_alone_is_still_accepted() + { + var model = Valid(); + model.Role = (int)Domain.Accounts.AccountRole.Member; + + // Older callers send only this, and they must keep working. + Assert.IsTrue(Validator().Validate(model).IsValid); + } + + [TestMethod] + public void Naming_no_role_at_all_is_refused() + { + // The rule the original NotNull was really protecting. When Role was a plain int, omitting + // it sent 0 — which is Admin — so a member added with nothing ticked got the run of the + // instance. Neither field given has to stay a refusal. + CollectionAssert.Contains(FailedFields(Valid()), nameof(CreateAccountModel.Role)); + } + + [TestMethod] + public void An_empty_role_id_list_counts_as_naming_no_role() + { + var model = Valid(); + model.RoleIds = []; + + // An empty list is not an answer — it is the absence of one, and must not slip past the + // guard just because the property was present. + CollectionAssert.Contains(FailedFields(model), nameof(CreateAccountModel.Role)); + } + + [TestMethod] + public void A_member_still_needs_a_name_an_email_and_a_password() + { + var failed = FailedFields(new CreateAccountModel { RoleIds = [Domain.Accounts.Role.MemberId] }); + + CollectionAssert.Contains(failed, nameof(CreateAccountModel.Name)); + CollectionAssert.Contains(failed, nameof(CreateAccountModel.Email)); + CollectionAssert.Contains(failed, nameof(CreateAccountModel.Password)); + } + + [TestMethod] + public void No_password_is_required_when_the_instance_signs_in_through_Microsoft_only() + { + var model = Valid(); + model.Password = null; + model.RoleIds = [Domain.Accounts.Role.MemberId]; + + // The account exists purely to be matched by email, so there is no password to demand. + Assert.IsTrue(Validator(disableEmailPasswordLogin: true).Validate(model).IsValid); + } +} diff --git a/SW.Bitween.UnitTests/MigrationDriftTests.cs b/SW.Bitween.UnitTests/MigrationDriftTests.cs new file mode 100644 index 00000000..1a7c8d07 --- /dev/null +++ b/SW.Bitween.UnitTests/MigrationDriftTests.cs @@ -0,0 +1,89 @@ +using System; +using Microsoft.EntityFrameworkCore; +using Microsoft.VisualStudio.TestTools.UnitTesting; +using SW.PrimitiveTypes; + +namespace SW.Bitween.UnitTests; + +/// +/// Asserts each provider's model still matches its newest migration. +/// +/// This is the same check EF runs during Database.Migrate() at startup: when the model and +/// the latest migration's snapshot disagree, the app throws PendingModelChangesWarning and the +/// process dies before it serves anything. Without a test, that only ever surfaces as a pod +/// crash-looping in whichever environment was unlucky enough to deploy first. +/// +/// Every provider is covered deliberately. MsSql and MySql inherit the base context's seed data +/// (they call base.OnModelCreating; PgSql re-declares the model instead), so a change to shared +/// seed values silently drifts their snapshots even when PgSql's has been regenerated — which is +/// exactly what happened when the seed timestamp fix was first applied to PgSql alone. +/// +/// No database is touched: this compares the in-memory model against the compiled-in snapshot, +/// so the connection strings below are never opened. +/// +[TestClass] +public class MigrationDriftTests +{ + [TestMethod] + public void PgSql_model_matches_its_latest_migration() => + AssertNoPendingChanges(BuildPgSql()); + + [TestMethod] + public void MsSql_model_matches_its_latest_migration() => + AssertNoPendingChanges(BuildMsSql()); + + [TestMethod] + public void MySql_model_matches_its_latest_migration() => + AssertNoPendingChanges(BuildMySql()); + + private static void AssertNoPendingChanges(DbContext dbContext) + { + using (dbContext) + { + Assert.IsFalse(dbContext.Database.HasPendingModelChanges(), + $"{dbContext.GetType().FullName} has model changes that no migration covers. " + + "The app will refuse to start against this provider. Generate a migration for it — " + + "and remember a shared-model change usually needs one per provider."); + } + } + + // Mirrors Startup's registration for each provider: the migrations assembly (and, for PgSql, + // the naming convention and history table) all take part in the comparison, so a test that + // configured them differently would be comparing something the app never builds. + + private static PgSql.BitweenDbContext BuildPgSql() + { + var options = new DbContextOptionsBuilder() + .UseSnakeCaseNamingConvention() + .UseNpgsql("Host=localhost;Database=unused;Username=unused;Password=unused", b => + { + b.MigrationsHistoryTable("_ef_migrations_history", PgSql.BitweenDbContext.Schema); + b.MigrationsAssembly(typeof(PgSql.DbType).Assembly.FullName); + }) + .Options; + + return new PgSql.BitweenDbContext(options, new RequestContext(), null); + } + + private static MsSql.BitweenDbContext BuildMsSql() + { + var options = new DbContextOptionsBuilder() + .UseSqlServer("Server=unused;Database=unused;Integrated Security=true", + b => b.MigrationsAssembly(typeof(MsSql.DbType).Assembly.FullName)) + .Options; + + return new MsSql.BitweenDbContext(options, new RequestContext(), null); + } + + private static MySql.BitweenDbContext BuildMySql() + { + // An explicit server version, never AutoDetect — the latter opens a connection. + var options = new DbContextOptionsBuilder() + .UseMySql("Server=unused;Database=unused;User=unused;Password=unused", + new MySqlServerVersion(new Version(8, 0, 18)), + b => b.MigrationsAssembly(typeof(MySql.DbType).Assembly.FullName)) + .Options; + + return new MySql.BitweenDbContext(options, new RequestContext(), null); + } +} diff --git a/SW.Bitween.UnitTests/SW.Bitween.UnitTests.csproj b/SW.Bitween.UnitTests/SW.Bitween.UnitTests.csproj index d0bd3a45..188a86de 100644 --- a/SW.Bitween.UnitTests/SW.Bitween.UnitTests.csproj +++ b/SW.Bitween.UnitTests/SW.Bitween.UnitTests.csproj @@ -24,6 +24,12 @@ + + + + diff --git a/SW.Bitween.Web/ClientApp/src/index.css b/SW.Bitween.Web/ClientApp/src/index.css index f0aa6412..3618b5be 100644 --- a/SW.Bitween.Web/ClientApp/src/index.css +++ b/SW.Bitween.Web/ClientApp/src/index.css @@ -39,8 +39,11 @@ --color-canvas: #faf8f7; + --color-ok-800: #145c39; --color-ok-600: #1a7f4e; + --color-ok-200: #b8e6ce; --color-ok-100: #d9f2e5; + --color-ok-50: #f0faf5; /* Warn reads YELLOW, not brown. The old pair was a tan fill (#fbeed0) with an olive text (#92600a), and since a badge takes its perceived colour from the @@ -53,6 +56,7 @@ */ --color-warn-700: #a16207; --color-warn-400: #eab308; + --color-warn-300: #f5d949; --color-warn-100: #fef7c3; /* diff --git a/SW.Bitween.Web/ClientApp/src/pages/team/MemberDrawer.tsx b/SW.Bitween.Web/ClientApp/src/pages/team/MemberDrawer.tsx index 697140ba..68a66e40 100644 --- a/SW.Bitween.Web/ClientApp/src/pages/team/MemberDrawer.tsx +++ b/SW.Bitween.Web/ClientApp/src/pages/team/MemberDrawer.tsx @@ -163,9 +163,30 @@ export function MemberDrawer({ userId, onClose }: { userId: string; onClose: ()

- They can sign in with it now. Bitween sends no email, so copy it and pass it on - yourself — it won't be shown again once you dismiss this. + Bitween sends no email, so copy it and pass it on yourself — it won't be shown + again once you dismiss this.

+ {u.lockedUntil ? ( +
+

+ They still can't sign in. The account is locked after repeated failed + sign-ins, and a new password doesn't clear that — this is the other half. +

+ + {unlock.error?.message} +
+ ) : u.status === "disabled" ? ( +
+

+ They still can't sign in. The account is disabled, and a new password + doesn't change that — re-enable it below to let them back in. +

+
+ ) : ( +

They can sign in with it now.

+ )}

- Locked after repeated failed sign-ins, for another {timeUntil(u.lockedUntil)}. - Unlocking clears it now. + Locked after repeated failed sign-ins; it clears on its own{" "} + {timeUntil(u.lockedUntil)}. Unlocking clears it now — setting a password does + not.

{unlock.error?.message}