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
46 changes: 46 additions & 0 deletions .github/workflows/frontend-tests.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
# Runs the ClientApp's unit and component tests (vitest) on every pull request that touches
# the UI.
#
# The deploy pipeline only runs the .NET test projects, and only after a merge, so until
# this existed nothing ever ran the frontend tests outside someone's machine. These need
# no backend or database — the component tests render pages against a mock network — so
# they take seconds. The Playwright e2e suite is not run here: it needs a running backend
# with seeded data.
name: Frontend tests

on:
pull_request:
branches: [releases/**]
paths:
- 'SW.Bitween.Web/ClientApp/**'
# Read by the tests: transformArgs.test.ts checks the editor names arguments as the engine
# does, and the component tests sign in holding every permission the catalog defines.
- 'SW.Bitween.NativeAdapters/Mapper/Transforms.cs'
- 'SW.Bitween.Sdk/Model/Permissions.cs'
- '.github/workflows/frontend-tests.yml'

permissions:
contents: read

jobs:
vitest:
runs-on: ubuntu-latest
defaults:
run:
working-directory: SW.Bitween.Web/ClientApp
steps:
# Pinned to commits, since a tag can be moved; and the checkout keeps no token, since the
# install below runs dependencies' own scripts.
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0
with:
persist-credentials: false
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
with:
node-version: '22' # matches the Dockerfile's ui-build stage
cache: yarn
cache-dependency-path: SW.Bitween.Web/ClientApp/yarn.lock
- run: yarn install --frozen-lockfile
# The same type-check the Docker build runs, which covers the tests too — so a test file
# that breaks it fails here rather than in the deploy after the merge.
- run: npx tsc -b
- run: yarn test
17 changes: 17 additions & 0 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -266,8 +266,25 @@ dotnet test SW.Bitween.UnitTests

# Run tests with coverage
dotnet test --collect:"XPlat Code Coverage"

# Frontend, from SW.Bitween.Web/ClientApp
yarn test # unit + component tests, no backend needed
yarn test:e2e # end-to-end, needs the backend running on the Local profile
```

### Where a New Test Goes

Four layers, fastest first. Put a test in the lowest layer that can see the bug it guards against.

| Layer | Where | Use it for |
|---|---|---|
| Unit | `SW.Bitween.UnitTests` (MSTest); `ClientApp/src/**/__tests__/*.test.ts` (vitest) | Logic with no database, bus or screen: the mapping engine, transforms, schedules, parsing, the editor's reducer. |
| Integration | `SW.Bitween.IntegrationTests` (xUnit, real Postgres and RabbitMQ in Docker) | Anything that crosses into the database, the bus, or a permission guard. |
| Component | `ClientApp/src/**/__tests__/*.test.tsx` (vitest in jsdom, against a mock network — see `src/__tests__/support`) | What one page shows and does: which controls appear, what a click sends, how an error reads. |
| End-to-end | `ClientApp/e2e` (Playwright, against the running backend) | A whole user journey through the real app, and what only a real browser can check: layout widths, drag and drop, two tabs. |

An end-to-end test that asserts what the engine returns, or what one page renders, belongs a layer down: it runs far slower, and fails for reasons that have nothing to do with what it tests.

### Writing Tests

#### Unit Tests
Expand Down
15 changes: 14 additions & 1 deletion SW.Bitween.IntegrationTests/Tests/AuditTrailTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -179,7 +179,20 @@ static async Task<AuditEntry> SingleEntryFor(BitweenDbContext db, string entityN
await EntriesFor(db, entityName, key).SingleAsync();

static Dictionary<string, Diff> Changes(AuditEntry entry) =>
Newtonsoft.Json.JsonConvert.DeserializeObject<Dictionary<string, Diff>>(entry.Changes);
Newtonsoft.Json.Linq.JObject.Parse(entry.Changes).Properties().ToDictionary(
p => p.Name,
p => new Diff { Old = Text(p.Value["Old"]), New = Text(p.Value["New"]) });

/// <summary>
/// A value as the test compares it. Most columns are recorded as text, but a collection — the
/// names of a partner's secret properties, say — is recorded as the list it is.
/// </summary>
static string? Text(Newtonsoft.Json.Linq.JToken? value) => value?.Type switch
{
null or Newtonsoft.Json.Linq.JTokenType.Null => null,
Newtonsoft.Json.Linq.JTokenType.String => (string?)value,
_ => value.ToString(Newtonsoft.Json.Formatting.None),
};

private class Diff
{
Expand Down
47 changes: 47 additions & 0 deletions SW.Bitween.IntegrationTests/Tests/MappingPreviewTests.cs
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;
using Microsoft.Extensions.DependencyInjection;
using Newtonsoft.Json;
Expand Down Expand Up @@ -146,6 +147,52 @@ public async Task An_unsupported_format_names_what_is_supported()
response.Error);
}

/// <summary>Rules that write a delimited file, with the target side's options left to the caller.</summary>
private static object CsvTargetRules(object targetCsv) => new
{
version = 1,
sourceFormat = "json",
targetFormat = "csv",
targetCsv,
root = new
{
over = "",
fields = new object[]
{
new { target = new[] { "Tracking" }, from = new { kind = "Path", path = "t" } },
new { target = new[] { "Status" }, from = new { kind = "Path", path = "s" } },
},
},
};

private const string Scans = """[ { "t": "1309981172", "s": "OK" }, { "t": "1309981174", "s": "CC" } ]""";

/// <summary>
/// The writer's own tests prove it honours a delimiter and a header. This pins that the ones on
/// the rules are the ones it is handed — the preview builds its target format from them.
/// </summary>
[Fact]
public async Task A_delimited_file_is_written_with_the_delimiter_and_header_the_rules_ask_for()
{
var response = await PreviewAsync(CsvTargetRules(new { delimiter = ";", hasHeader = true }), Scans);

Assert.Null(response.Error);
var lines = response.OutputDocument!.Split('\n', StringSplitOptions.RemoveEmptyEntries);
Assert.Equal(new[] { "Tracking;Status", "1309981172;OK", "1309981174;CC" },
lines.Select(l => l.TrimEnd('\r')).ToArray());
}

/// <summary>The mark is invisible, so what is checked is the first character itself.</summary>
[Fact]
public async Task A_delimited_file_starts_with_a_byte_order_mark_only_when_asked()
{
var marked = await PreviewAsync(CsvTargetRules(new { byteOrderMark = true }), Scans);
var plain = await PreviewAsync(CsvTargetRules(new { byteOrderMark = false }), Scans);

Assert.Equal('\uFEFF', marked.OutputDocument![0]);
Assert.NotEqual('\uFEFF', plain.OutputDocument![0]);
}

[Fact]
public async Task Rules_from_a_newer_version_are_refused()
{
Expand Down
90 changes: 90 additions & 0 deletions SW.Bitween.IntegrationTests/Tests/ReadGuardTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
using System;
using System.Collections.Generic;
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;

/// <summary>
/// Reads are guarded as well as writes, and in both directions: a narrow role must not read the
/// lists of areas it doesn't hold, yet every picker in the app needs id/name pairs from areas the
/// person may never browse. Lookup mode is what serves the pickers, so it stays open.
/// </summary>
[Collection("Bitween")]
public class ReadGuardTests(BitweenFixture fixture)
{
/// <summary>Signs in as a fresh account whose only grant is reading information types.</summary>
private static async Task SignInAsInformationTypeReader(AsyncServiceScope scope)
{
var db = scope.ServiceProvider.GetRequiredService<BitweenDbContext>();

var role = new Role($"docs-reader-{Guid.NewGuid():N}", "Reads information types only",
[Permissions.Documents.View]);
db.Set<Role>().Add(role);
var account = new Account("Docs Reader", $"docs-reader-{Guid.NewGuid():N}@test.local", "hash",
AccountRole.Member);
db.Set<Account>().Add(account);
await db.SaveChangesAsync();

db.Set<AccountRoleLink>().Add(new AccountRoleLink(account.Id, role.Id));
await db.SaveChangesAsync();

scope.As(account.Id);
}

private static Func<Task<object>> Search<THandler>(AsyncServiceScope scope, bool lookup = false)
where THandler : ISearchyHandler =>
() => ActivatorUtilities.CreateInstance<THandler>(scope.ServiceProvider)
.Handle(new SearchyRequest(), lookup);

[Fact]
public async Task One_view_permission_reads_its_own_list_and_no_other()
{
await using var scope = fixture.CreateScope();
await SignInAsInformationTypeReader(scope);

// The one area held is readable.
await Search<Resources.Documents.Search>(scope)();

var refused = new Dictionary<string, Func<Task<object>>>
{
["partners"] = Search<Resources.Partners.Search>(scope),
["exchanges"] = Search<Resources.Xchanges.Search>(scope),
["subscriptions"] = Search<Resources.Subscriptions.Search>(scope),
["notifiers"] = Search<Resources.Notifiers.Search>(scope),
["API gateways"] = Search<Resources.ApiGateways.Search>(scope),
["bus gateways"] = Search<Resources.BusGateways.Search>(scope),
["retry policies"] = Search<Resources.RetryPolicies.Search>(scope),
["global values"] = Search<Resources.GlobalAdapterValuesSets.Search>(scope),
["scheduled retries"] = Search<Resources.DelayedRetries.Search>(scope),
["work groups"] = () => ActivatorUtilities.CreateInstance<Resources.WorkGroups.Search>(
scope.ServiceProvider).Handle(new SearchWorkGroupModel()),
["queue health"] = () => ActivatorUtilities.CreateInstance<Resources.Ops.Summary>(
scope.ServiceProvider).Handle(),
};

foreach (var (area, read) in refused)
{
var denied = await Record.ExceptionAsync(read);
Assert.True(denied is SWUnauthorizedException, $"{area} should be refused, got {denied?.GetType().Name ?? "a result"}");
}
}

[Fact]
public async Task Lookup_mode_stays_readable_so_pickers_work_for_any_role()
{
await using var scope = fixture.CreateScope();
await SignInAsInformationTypeReader(scope);

await Search<Resources.Partners.Search>(scope, lookup: true)();
await Search<Resources.Subscriptions.Search>(scope, lookup: true)();
await Search<Resources.RetryPolicies.Search>(scope, lookup: true)();
await ActivatorUtilities.CreateInstance<Resources.Accounts.Search>(scope.ServiceProvider)
.Handle(new SearchMembersModel { Lookup = true });
}
}
35 changes: 35 additions & 0 deletions SW.Bitween.IntegrationTests/Tests/SettingsTests.cs
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.DependencyInjection;
Expand Down Expand Up @@ -27,6 +28,7 @@ public class SettingsTests(BitweenFixture fixture) : IAsyncLifetime
private const string SecretKey = "Bitween.RebexLicenseKey";
private const string EditableKey = "Bitween.JwtExpiryMinutes";
private const string EnvironmentOwnedKey = "Bitween.DocumentPrefix";
private const string CronKey = "Bitween.RetryJobCron";

private readonly Dictionary<string, string> _originals = new();

Expand Down Expand Up @@ -166,4 +168,37 @@ public async Task Clearing_an_optional_setting_is_a_real_change_not_a_no_op()
// do" would make a setting impossible to unset once set.
Assert.Equal(string.Empty, await LiveValue(SecretKey));
}

/// <summary>
/// What the settings page is sent for a secret: that one is set, and nothing more. Storing it
/// encrypted protects a database dump; this is the other half, the page itself.
/// </summary>
[Fact]
public async Task A_secret_is_read_back_as_set_but_never_with_its_value()
{
await Store(SecretKey, "REBEX-5678-SECRET-VALUE");

await using var scope = fixture.CreateScope();
scope.Superuser();
var handler = ActivatorUtilities.CreateInstance<Resources.Settings.Get>(scope.ServiceProvider);
var row = ((IEnumerable<SettingRow>)await handler.Handle()).Single(r => r.Key == SecretKey);

Assert.True(row.Secret);
Assert.True(row.HasValue);
Assert.Null(row.Value);
Assert.Equal(string.Empty, row.DefaultValue);
}

/// <summary>
/// Checked before it is stored, because the value is handed straight to the job scheduler: a
/// bad expression saved here would surface as the retry job never running.
/// </summary>
[Fact]
public async Task A_schedule_that_is_not_a_cron_expression_is_refused()
{
var ex = await Assert.ThrowsAsync<SWValidationException>(() => Store(CronKey, "not a cron"));

Assert.StartsWith("SETTING_INVALID_VALUE", ex.Message);
Assert.Contains("not a valid cron expression", ex.Message);
}
}
Loading
Loading