Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 5 additions & 1 deletion SW.Bitween.Api/Resources/Accounts/Create.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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 });
}
}
}
Expand Down
5 changes: 5 additions & 0 deletions SW.Bitween.Api/Resources/Accounts/Login.cs
Original file line number Diff line number Diff line change
Expand Up @@ -141,7 +141,12 @@ public async Task<object> 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
Expand Down
9 changes: 8 additions & 1 deletion SW.Bitween.Api/Resources/Adapters/GetProperties.cs
Original file line number Diff line number Diff line change
Expand Up @@ -13,15 +13,22 @@ public class GetProperties : IGetHandler<string,object>
{
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<object> Handle(string key)
{
await requestContext.EnsurePermission(dbContext, Model.Permissions.Subscriptions.View);

var decodedKey = Uri.UnescapeDataString(key);

// Check if it's a native adapter
Expand Down
11 changes: 9 additions & 2 deletions SW.Bitween.Api/Resources/Adapters/GetStartupValues.cs
Original file line number Diff line number Diff line change
Expand Up @@ -13,17 +13,24 @@ public class GetStartupValues : IGetHandler<string, IDictionary<string, StartupV
{
private readonly IServerlessService serverless;
private readonly NativeAdapterDiscoveryService _nativeAdapterDiscovery;
private readonly BitweenDbContext dbContext;
private readonly RequestContext requestContext;

public GetStartupValues(IServerlessService serverless, NativeAdapterDiscoveryService nativeAdapterDiscovery)
public GetStartupValues(IServerlessService serverless, NativeAdapterDiscoveryService nativeAdapterDiscovery,
BitweenDbContext dbContext, RequestContext requestContext)
{
this.serverless = serverless;
_nativeAdapterDiscovery = nativeAdapterDiscovery;
this.dbContext = dbContext;
this.requestContext = requestContext;
}



public async Task<IDictionary<string, StartupValue>> Handle(string key)
{
await requestContext.EnsurePermission(dbContext, Model.Permissions.Subscriptions.View);

var decodedKey = Uri.UnescapeDataString(key);

IDictionary<string, StartupValue> startupValues = new Dictionary<string, StartupValue>();
Expand Down
10 changes: 9 additions & 1 deletion SW.Bitween.Api/Resources/Adapters/Metadata.cs
Original file line number Diff line number Diff line change
Expand Up @@ -11,20 +11,28 @@ public class Metadata : IGetHandler<string, object>
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<object> Handle(string key)
{
await _requestContext.EnsurePermission(_dbContext, Model.Permissions.Subscriptions.View);

var decodedKey = Uri.UnescapeDataString(key);

if (decodedKey.StartsWith(NativeAdapterDiscoveryService.NativePrefix, StringComparison.OrdinalIgnoreCase))
Expand Down
9 changes: 8 additions & 1 deletion SW.Bitween.Api/Resources/Adapters/Search.cs
Original file line number Diff line number Diff line change
Expand Up @@ -11,18 +11,25 @@ public class Search : IQueryHandler<AdapterSearchRequest,object>
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<object> Handle(AdapterSearchRequest request)
{
await _requestContext.EnsurePermission(_dbContext, Model.Permissions.Subscriptions.View);

// Get native adapters first
var nativeAdapters = _nativeAdapterDiscovery.GetNativeAdapters(request.Prefix).ToList();

Expand Down
9 changes: 8 additions & 1 deletion SW.Bitween.Api/Resources/Adapters/SearchVersioned.cs
Original file line number Diff line number Diff line change
Expand Up @@ -13,18 +13,25 @@ public class SearchVersioned : IQueryHandler<AdapterSearchRequest,object>
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<object> 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)
Expand Down
6 changes: 6 additions & 0 deletions SW.Bitween.Api/Resources/Documents/Update.cs
Original file line number Diff line number Diff line change
Expand Up @@ -91,6 +91,12 @@ public async Task<object> 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);
Expand Down
20 changes: 15 additions & 5 deletions SW.Bitween.Api/Resources/Xchanges/StatusList.cs
Original file line number Diff line number Diff line change
Expand Up @@ -9,17 +9,27 @@ namespace SW.Bitween.Resources.Xchanges
[HandlerName("statuslist")]
public class StatusList : ISearchyHandler
{
public Task<object> 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<object> Handle(SearchyRequest searchyRequest, bool lookup = false, string searchPhrase = null)
{
//throw new NotImplementedException();
return Task.FromResult<object>(new Dictionary<string, string>
await requestContext.EnsurePermission(dbContext, Model.Permissions.Exchanges.View);

return new Dictionary<string, string>
{
{"0", "Running" },
{"1", "Success" },
{"2", "Success with bad response" },
{"3", "Failed" },
});

};
}
}
}
36 changes: 36 additions & 0 deletions SW.Bitween.IntegrationTests/Fixtures/BitweenFixture.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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.
/// </summary>
/// <remarks>
/// 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 <c>new Document(null, name, format)</c>, the constructor that exists for exactly this.
/// </remarks>
public sealed class BitweenFixture : IAsyncLifetime
{
private readonly PostgreSqlContainer _postgres = new PostgreSqlBuilder().Build();
Expand Down Expand Up @@ -74,6 +85,11 @@ public async Task InitializeAsync()
.ConfigureAppConfiguration(cfg => cfg.AddInMemoryCollection(new Dictionary<string, string?>
{
["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) =>
{
Expand All @@ -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<SettingsProtector>();
services.AddSingleton<SettingsService>();

// 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<RequestContext>();

Expand Down Expand Up @@ -120,8 +150,14 @@ public async Task InitializeAsync()
services.AddScoped<INativeInfolinkHandler, NativeSmtpHandler>();
services.AddScoped<INativeAdapter, NativeSmtpHandler>();

// See RecordingScheduleRepository: the create/update handlers need a scheduler
// to construct, and a real Quartz store would fire background jobs mid-test.
services.AddSingleton<IScheduleRepository, RecordingScheduleRepository>();
services.AddScoped<SubscriptionSchedulerService>();

services.AddSingleton<FilterService>();
services.AddScoped<NativeAdapterDiscoveryService>();
services.AddScoped<AdapterRequirements>();
services.AddScoped<AdapterSecretProperties>();
services.AddScoped<RetryUsageReport>();
services.AddScoped<AdapterInvoker>();
Expand Down
Original file line number Diff line number Diff line change
@@ -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;

/// <summary>
/// Stands in for Quartz, recording what would have been scheduled instead of scheduling it.
/// </summary>
/// <remarks>
/// 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.
/// </remarks>
internal sealed class RecordingScheduleRepository : IScheduleRepository
{
/// <summary>Schedule keys currently registered, so a test can assert on them if it needs to.</summary>
public ConcurrentDictionary<string, string> Scheduled { get; } = new();

public Task Schedule<TScheduler, TParam>(TParam param, string cronExpression, string scheduleKey,
ScheduleConfig? config = null) where TScheduler : IScheduledJob<TParam>
{
Scheduled[scheduleKey] = cronExpression;
return Task.CompletedTask;
}

public Task Schedule<TScheduler>(string cronExpression, ScheduleConfig? config = null)
where TScheduler : IScheduledJob
{
Scheduled[typeof(TScheduler).FullName!] = cronExpression;
return Task.CompletedTask;
}

public Task<string> ScheduleOnce<TScheduler, TParam>(TParam param, DateTime? runAt = null,
ScheduleConfig? config = null) where TScheduler : IScheduledJob<TParam>
=> Task.FromResult(Guid.NewGuid().ToString("N"));

public Task RescheduleJob<TScheduler, TParam>(string scheduleKey, string newCronExpression)
where TScheduler : IScheduledJob<TParam>
{
Scheduled[scheduleKey] = newCronExpression;
return Task.CompletedTask;
}

public Task RescheduleJob<TScheduler>(string newCronExpression) where TScheduler : IScheduledJob
{
Scheduled[typeof(TScheduler).FullName!] = newCronExpression;
return Task.CompletedTask;
}

public Task UnscheduleJob<TScheduler, TParam>(string scheduleKey) where TScheduler : IScheduledJob<TParam>
{
Scheduled.TryRemove(scheduleKey, out _);
return Task.CompletedTask;
}

public Task UnscheduleJob<TScheduler>() where TScheduler : IScheduledJob
{
Scheduled.TryRemove(typeof(TScheduler).FullName!, out _);
return Task.CompletedTask;
}

public Task PauseJob<TScheduler, TParam>(string scheduleKey) where TScheduler : IScheduledJob<TParam>
=> Task.CompletedTask;

public Task PauseJob<TScheduler>() where TScheduler : IScheduledJob => Task.CompletedTask;

public Task ResumeJob<TScheduler, TParam>(string scheduleKey) where TScheduler : IScheduledJob<TParam>
=> Task.CompletedTask;

public Task ResumeJob<TScheduler>() where TScheduler : IScheduledJob => Task.CompletedTask;

public Task<bool> ScheduleIfNotExists<TScheduler, TParam>(TParam param, string cronExpression,
string scheduleKey, ScheduleConfig? config = null) where TScheduler : IScheduledJob<TParam>
=> Task.FromResult(Scheduled.TryAdd(scheduleKey, cronExpression));

/// <summary>Job discovery happens at startup against the real scheduler; nothing here needs it.</summary>
public IEnumerable<IScheduledJobDefinition> GetJobDefinitions() => [];
}
Loading
Loading