Merge origin/main into hands/mill-votes-cut-5

This commit is contained in:
Meta Vens 2026-10-11 00:06:51 +02:00
commit 0c6f9216b6
8 changed files with 1141 additions and 8 deletions

View file

@ -1,5 +1,4 @@
using System.Net;
using System.Text.RegularExpressions;
using Bifrost.Web.Configuration;
using Bifrost.Web.Data;
using Bifrost.Web.Domain;
@ -40,8 +39,6 @@ public sealed class ConceptAppraisalService(
{
private const string UniqueViolation = "23505";
private static readonly Regex Slug = new("^[a-z0-9-]{1,80}$", RegexOptions.CultureInvariant);
public async Task<CastResult> CastAsync(
string collection,
string key,
@ -260,7 +257,7 @@ public sealed class ConceptAppraisalService(
}
private Task<ConceptSubject?> FindSubjectAsync(string collection, string key, CancellationToken cancellationToken) =>
Slug.IsMatch(collection) && Slug.IsMatch(key)
ConceptKey.IsValid(collection) && ConceptKey.IsValid(key)
? dbContext.ConceptSubjects
.SingleOrDefaultAsync(x => x.Collection == collection && x.Key == key, cancellationToken)
: Task.FromResult<ConceptSubject?>(null);

View file

@ -0,0 +1,70 @@
using Microsoft.Extensions.DependencyInjection;
namespace Bifrost.Web.Features.ConceptVotes;
// Host command: Bifrost.Web.dll concepts plan|apply <collection> <manifest.json> [--retire k1,k2]
public static class ConceptCommand
{
public const int Ok = 0;
public const int Refused = 1;
public const int Usage = 2;
// args are the words after "concepts".
public static async Task<int> RunAsync(string[] args, IServiceProvider services, TextWriter output)
{
var known = args.Length > 0 && args[0] is "plan" or "apply";
var hasRetire = args.Length == 5 && args[3] == "--retire";
if (!known || !(args.Length == 3 || hasRetire))
{
await output.WriteLineAsync("usage: concepts plan|apply <collection> <manifest.json> [--retire k1,k2]");
return Usage;
}
string[] retire = hasRetire ? args[4].Split(',', StringSplitOptions.RemoveEmptyEntries) : [];
try
{
var ships = ConceptSubjectRegistry.ParseManifest(await ReadManifestAsync(args[2]));
await using var scope = services.CreateAsyncScope();
var registry = scope.ServiceProvider.GetRequiredService<ConceptSubjectRegistry>();
var plan = args[0] == "apply"
? await registry.ApplyAsync(args[1], ships, retire, default)
: await registry.PlanAsync(args[1], ships, retire, default);
foreach (var entry in plan.Entries)
{
await output.WriteLineAsync($"{entry.Change.ToString().ToLowerInvariant()} {entry.Key}");
}
return Ok;
}
catch (ManifestRefusedException ex)
{
await output.WriteLineAsync($"refused {ex.Field} {ex.Code}");
return Refused;
}
}
// Reads at most one byte past the bound, so a wrong file is never read whole.
private static async Task<byte[]> ReadManifestAsync(string path)
{
try
{
await using var stream = new FileStream(path, FileMode.Open, FileAccess.Read, FileShare.Read, 1, true);
var buffer = new byte[ConceptSubjectRegistry.MaxManifestBytes + 1];
var length = 0;
int read;
while (length < buffer.Length && (read = await stream.ReadAsync(buffer.AsMemory(length))) > 0)
{
length += read;
}
return buffer[..length];
}
// An empty or NUL path is an ArgumentException; a path the platform cannot name is NotSupported.
catch (Exception ex) when (ex is IOException or UnauthorizedAccessException or ArgumentException
or NotSupportedException or System.Security.SecurityException)
{
throw new ManifestRefusedException("manifest", "Unreadable");
}
}
}

View file

@ -0,0 +1,13 @@
using System.Text.RegularExpressions;
namespace Bifrost.Web.Features.ConceptVotes;
// The one grammar for a concept collection or key. \z, not $: $ admits a trailing line feed.
public static class ConceptKey
{
public const int MaxLength = 80;
private static readonly Regex Shape = new(@"^[a-z0-9-]{1,80}\z", RegexOptions.CultureInvariant);
public static bool IsValid(string? value) => value is not null && Shape.IsMatch(value);
}

View file

@ -0,0 +1,352 @@
using System.Globalization;
using System.Text;
using System.Text.Json;
using Bifrost.Web.Data;
using Bifrost.Web.Domain;
using Bifrost.Web.Features.Shared;
using Microsoft.EntityFrameworkCore;
namespace Bifrost.Web.Features.ConceptVotes;
public enum SubjectChange
{
Add,
Rename,
Unchanged,
Revive,
Absent,
Retire,
Retired
}
public sealed record ManifestShip(string Key, string Title);
public sealed record SubjectPlanEntry(SubjectChange Change, string Key, string Title);
public sealed record SubjectPlan(string Collection, IReadOnlyList<SubjectPlanEntry> Entries);
// A refusal names the field and a code, never the offending value.
public sealed class ManifestRefusedException(string field, string code)
: Exception($"{field}: {code}")
{
public string Field { get; } = field;
public string Code { get; } = code;
}
// The only writer of ConceptSubject: decides which concept keys exist, their titles and their retirement.
public sealed class ConceptSubjectRegistry(
BifrostDbContext dbContext,
AuditTrailService auditTrail,
TimeProvider timeProvider)
{
public const int MaxTitleLength = 240;
// Defaulted bounds (a Mill manifest is tens of ships): the whole file is refused above either.
public const int MaxManifestBytes = 1024 * 1024;
public const int MaxShips = 1000;
private static readonly UTF8Encoding StrictUtf8 = new(false, true);
// The Mill manifest is JSON from outside the Verse. Only ships[].id and ships[].name are read.
// This is the one input boundary: every malformed input leaves as a ManifestRefusedException.
public static IReadOnlyList<ManifestShip> ParseManifest(ReadOnlyMemory<byte> utf8)
{
using var document = ParseDocument(utf8);
var ships = ShipsOf(document.RootElement);
if (ships.ValueKind != JsonValueKind.Array)
{
throw new ManifestRefusedException("ships", "Missing");
}
if (ships.GetArrayLength() > MaxShips)
{
throw new ManifestRefusedException("ships", "TooMany");
}
var result = new List<ManifestShip>();
var seen = new HashSet<string>(StringComparer.Ordinal);
foreach (var ship in ships.EnumerateArray())
{
var at = $"ships[{result.Count}]";
var key = ReadString(ship, "id", at);
var title = ReadString(ship, "name", at);
if (!ConceptKey.IsValid(key))
{
throw new ManifestRefusedException($"{at}.id", "InvalidKey");
}
if (!seen.Add(key))
{
throw new ManifestRefusedException($"{at}.id", "Duplicate");
}
if (!IsValidTitle(title))
{
throw new ManifestRefusedException($"{at}.name", "InvalidTitle");
}
if (title.Length > MaxTitleLength)
{
throw new ManifestRefusedException($"{at}.name", "TooLong");
}
result.Add(new ManifestShip(key, title));
}
return result;
}
private static JsonElement ShipsOf(JsonElement root)
{
if (root.ValueKind != JsonValueKind.Object)
{
return default;
}
return root.TryGetProperty("ships", out var ships) ? ships : default;
}
// A displayed title: no control, format (bidi, zero-width, BOM), line or paragraph separator
// character, no leading or trailing whitespace, and at least one letter or digit. This is the
// character-class rule of the mind schema's Title type, kept here as the one copy in Bifrost.
private static bool IsValidTitle(string title)
{
if (title.Length == 0 || title != title.Trim())
{
return false;
}
var hasLetterOrDigit = false;
foreach (var rune in title.EnumerateRunes())
{
if (Rune.GetUnicodeCategory(rune) is UnicodeCategory.Control or UnicodeCategory.Format
or UnicodeCategory.LineSeparator or UnicodeCategory.ParagraphSeparator)
{
return false;
}
hasLetterOrDigit |= Rune.IsLetterOrDigit(rune);
}
return hasLetterOrDigit;
}
private static JsonDocument ParseDocument(ReadOnlyMemory<byte> utf8)
{
if (utf8.Length > MaxManifestBytes)
{
throw new ManifestRefusedException("manifest", "TooLarge");
}
try
{
StrictUtf8.GetCharCount(utf8.Span);
}
catch (DecoderFallbackException)
{
throw new ManifestRefusedException("manifest", "NotUtf8");
}
try
{
RejectDuplicateProperties(utf8.Span);
return JsonDocument.Parse(utf8);
}
catch (JsonException)
{
throw new ManifestRefusedException("manifest", "NotJson");
}
catch (InvalidOperationException)
{
// A property name holding a lone surrogate escape: not text.
throw new ManifestRefusedException("manifest", "BadText");
}
}
// System.Text.Json (net8.0) keeps the last of two equal property names; a manifest that says
// one thing twice is refused instead.
private static void RejectDuplicateProperties(ReadOnlySpan<byte> utf8)
{
var reader = new Utf8JsonReader(utf8);
var scopes = new Stack<HashSet<string>?>();
while (reader.Read())
{
switch (reader.TokenType)
{
case JsonTokenType.StartObject:
scopes.Push(new HashSet<string>(StringComparer.Ordinal));
break;
case JsonTokenType.StartArray:
scopes.Push(null);
break;
case JsonTokenType.EndObject:
case JsonTokenType.EndArray:
scopes.Pop();
break;
case JsonTokenType.PropertyName:
if (!scopes.Peek()!.Add(reader.GetString()!))
{
throw new ManifestRefusedException("manifest", "DuplicateProperty");
}
break;
}
}
}
public async Task<SubjectPlan> PlanAsync(
string collection,
IReadOnlyList<ManifestShip> ships,
IReadOnlyCollection<string> retire,
CancellationToken cancellationToken)
{
if (!ConceptKey.IsValid(collection))
{
throw new ManifestRefusedException("collection", "InvalidKey");
}
var named = new HashSet<string>(StringComparer.Ordinal);
foreach (var key in retire)
{
if (!ConceptKey.IsValid(key))
{
throw new ManifestRefusedException("retire", "InvalidKey");
}
named.Add(key);
}
var registered = await dbContext.ConceptSubjects.AsNoTracking()
.Where(x => x.Collection == collection)
.ToDictionaryAsync(x => x.Key, StringComparer.Ordinal, cancellationToken);
var entries = new List<SubjectPlanEntry>();
foreach (var ship in ships)
{
var change = !registered.TryGetValue(ship.Key, out var row)
? SubjectChange.Add
: row.RetiredAtUtc is not null
? SubjectChange.Revive
: row.Title != ship.Title ? SubjectChange.Rename : SubjectChange.Unchanged;
entries.Add(new SubjectPlanEntry(change, ship.Key, ship.Title));
}
var inManifest = ships.Select(x => x.Key).ToHashSet(StringComparer.Ordinal);
var absent = registered.Values
.Where(x => x.RetiredAtUtc is null && !inManifest.Contains(x.Key))
.OrderBy(x => x.Key, StringComparer.Ordinal)
.ToList();
foreach (var subject in absent)
{
entries.Add(new SubjectPlanEntry(
named.Remove(subject.Key) ? SubjectChange.Retire : SubjectChange.Absent,
subject.Key,
subject.Title));
}
// A named key that an earlier run already retired is done: a rerun converges.
foreach (var key in named.OrderBy(x => x, StringComparer.Ordinal).ToList())
{
if (registered.TryGetValue(key, out var done) && done.RetiredAtUtc is not null && !inManifest.Contains(key))
{
entries.Add(new SubjectPlanEntry(SubjectChange.Retired, key, done.Title));
named.Remove(key);
}
}
// Whatever is still named is not a registered key missing from the manifest.
if (named.Count > 0)
{
throw new ManifestRefusedException("retire", "NotAbsent");
}
return new SubjectPlan(collection, entries);
}
public async Task<SubjectPlan> ApplyAsync(
string collection,
IReadOnlyList<ManifestShip> ships,
IReadOnlyCollection<string> retire,
CancellationToken cancellationToken)
{
var plan = await PlanAsync(collection, ships, retire, cancellationToken);
var rows = await dbContext.ConceptSubjects
.Where(x => x.Collection == collection)
.ToDictionaryAsync(x => x.Key, StringComparer.Ordinal, cancellationToken);
foreach (var entry in plan.Entries)
{
if (entry.Change is SubjectChange.Unchanged or SubjectChange.Absent or SubjectChange.Retired)
{
continue;
}
ConceptSubject subject;
if (entry.Change == SubjectChange.Add)
{
subject = new ConceptSubject
{
Collection = collection,
Key = entry.Key,
Title = entry.Title,
RegisteredAtUtc = timeProvider.GetUtcNow()
};
dbContext.ConceptSubjects.Add(subject);
}
else
{
subject = rows[entry.Key];
if (entry.Change == SubjectChange.Retire)
{
subject.RetiredAtUtc = timeProvider.GetUtcNow();
}
else
{
subject.Title = entry.Title;
subject.RetiredAtUtc = null;
}
}
// The audit write saves the subject change in the same SaveChanges.
await auditTrail.RecordAsync(
null,
nameof(ConceptSubject),
subject.Id,
entry.Change.ToString(),
$"actor=host-command collection={collection} key={entry.Key}",
cancellationToken);
}
return plan;
}
private static string ReadString(JsonElement ship, string name, string at)
{
if (ship.ValueKind != JsonValueKind.Object)
{
throw new ManifestRefusedException($"{at}.{name}", "Missing");
}
if (!ship.TryGetProperty(name, out var value))
{
throw new ManifestRefusedException($"{at}.{name}", "Missing");
}
if (value.ValueKind != JsonValueKind.String)
{
throw new ManifestRefusedException($"{at}.{name}", "NotString");
}
try
{
return value.GetString()!;
}
catch (InvalidOperationException)
{
// A lone surrogate escape: not text.
throw new ManifestRefusedException($"{at}.{name}", "BadText");
}
}
}

View file

@ -24,6 +24,23 @@ using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Options;
using System.Net;
// Host command: registers Mill concept subjects with configuration and the database only; no web app, no migrations.
if (args.Length > 0 && args[0] == "concepts")
{
var host = Host.CreateApplicationBuilder([]);
host.Services.AddDbContext<BifrostDbContext>(options => options.UseNpgsql(BifrostConnectionString(host.Configuration)));
host.Services.AddSingleton(TimeProvider.System);
host.Services.AddScoped<AuditTrailService>();
host.Services.AddScoped<ConceptSubjectRegistry>();
using var commandHost = host.Build();
Environment.ExitCode = await ConceptCommand.RunAsync(args[1..], commandHost.Services, Console.Out);
return;
}
static string BifrostConnectionString(IConfiguration configuration) =>
configuration.GetConnectionString("Bifrost")
?? "Host=127.0.0.1;Database=bifrost;Username=postgres;Password=postgres";
var builder = WebApplication.CreateBuilder(args);
builder.Services
@ -71,10 +88,7 @@ builder.Services.ConfigureHttpJsonOptions(options =>
builder.Services.AddDbContext<BifrostDbContext>(options =>
{
var connectionString = builder.Configuration.GetConnectionString("Bifrost")
?? "Host=127.0.0.1;Database=bifrost;Username=postgres;Password=postgres";
options.UseNpgsql(connectionString);
options.UseNpgsql(BifrostConnectionString(builder.Configuration));
});
builder.Services.AddHttpLogging(options =>
@ -105,6 +119,7 @@ builder.Services.AddScoped<StripeWebhookService>();
builder.Services.AddScoped<HeimdallPatronSupportIntakeService>();
builder.Services.AddScoped<ConceptAppraisalService>();
builder.Services.AddScoped<VoterKeyResolver>();
builder.Services.AddScoped<ConceptSubjectRegistry>();
builder.Services.AddScoped<ReadinessService>();
builder.Services.AddScoped<ApplicationBootstrapper>();
builder.Services.AddSingleton<StartupConfigurationValidator>();

View file

@ -20,6 +20,10 @@
<ProjectReference Include="..\..\src\Bifrost.Web\Bifrost.Web.csproj" />
</ItemGroup>
<ItemGroup>
<None Include="Fixtures\*.json" CopyToOutputDirectory="PreserveNewest" />
</ItemGroup>
<ItemGroup>
<Using Include="Xunit" />
</ItemGroup>

View file

@ -0,0 +1,673 @@
using System.Diagnostics;
using System.Net;
using System.Text;
using System.Text.Encodings.Web;
using System.Text.Json;
using Bifrost.Web.Configuration;
using Bifrost.Web.Data;
using Bifrost.Web.Domain;
using Bifrost.Web.Features.ConceptVotes;
using Bifrost.Web.Features.Shared;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Options;
namespace Bifrost.Web.Tests;
public sealed class ConceptSubjectRegistryTests
{
private const string Collection = "aetheria-concept-mill";
private static readonly string[] None = [];
private static string FixturePath => Path.Combine(AppContext.BaseDirectory, "Fixtures", "mill-three-ships.json");
private static IReadOnlyList<ManifestShip> Parse(string json) =>
ConceptSubjectRegistry.ParseManifest(Encoding.UTF8.GetBytes(json));
private static IReadOnlyList<ManifestShip> Fixture() =>
ConceptSubjectRegistry.ParseManifest(File.ReadAllBytes(FixturePath));
[Fact]
public async Task Rerun_of_the_same_manifest_is_a_no_op()
{
using var world = new World();
var first = await world.Apply(Fixture());
Assert.All(first.Entries, e => Assert.Equal(SubjectChange.Add, e.Change));
Assert.Equal(3, await world.Fresh(db => db.AuditEvents.CountAsync(x => x.EntityType == nameof(ConceptSubject))));
var before = await world.Fresh(db => db.ConceptSubjects.AsNoTracking().OrderBy(x => x.Key).ToListAsync());
world.Clock.Advance(TimeSpan.FromHours(1));
var second = await world.Apply(Fixture());
Assert.All(second.Entries, e => Assert.Equal(SubjectChange.Unchanged, e.Change));
Assert.Equal(3, await world.Fresh(db => db.AuditEvents.CountAsync()));
var after = await world.Fresh(db => db.ConceptSubjects.AsNoTracking().OrderBy(x => x.Key).ToListAsync());
Assert.Equal(before.Select(x => (x.Id, x.Title, x.RegisteredAtUtc, x.RetiredAtUtc)),
after.Select(x => (x.Id, x.Title, x.RegisteredAtUtc, x.RetiredAtUtc)));
}
[Fact]
public async Task Apply_writes_one_host_command_audit_event_per_change_and_none_for_a_plan()
{
using var world = new World();
var plan = await world.Plan(Fixture());
Assert.All(plan.Entries, e => Assert.Equal(SubjectChange.Add, e.Change));
Assert.Empty(await world.Fresh(db => db.ConceptSubjects.ToListAsync()));
Assert.Empty(await world.Fresh(db => db.AuditEvents.ToListAsync()));
await world.Apply(Fixture());
var events = await world.Fresh(db => db.AuditEvents.ToListAsync());
var subjectIds = (await world.Fresh(db => db.ConceptSubjects.ToListAsync())).Select(x => x.Id.ToString()).ToHashSet();
Assert.Equal(3, events.Count);
Assert.All(events, e =>
{
Assert.Equal(nameof(ConceptSubject), e.EntityType);
Assert.Equal("Add", e.Action);
Assert.Null(e.ActorUserAccountId);
Assert.Contains("actor=host-command", e.Detail);
Assert.Contains(e.EntityId, subjectIds);
});
}
[Fact]
public async Task Rename_changes_title_and_keeps_votes()
{
using var world = new World();
await world.Apply(Fixture());
await world.Cast("bison", ConceptStance.Up);
await world.Cast("kestrel", ConceptStance.Down);
var renamed = Fixture().Select(s => s.Key == "bison" ? s with { Title = "Bison Prime" } : s).ToList();
var plan = await world.Apply(renamed);
Assert.Equal(SubjectChange.Rename, plan.Entries.Single(e => e.Key == "bison").Change);
Assert.Equal(2, plan.Entries.Count(e => e.Change == SubjectChange.Unchanged));
var subject = await world.Fresh(db => db.ConceptSubjects.AsNoTracking().SingleAsync(x => x.Key == "bison"));
Assert.Equal("Bison Prime", subject.Title);
Assert.Null(subject.RetiredAtUtc);
Assert.Equal(2, await world.Fresh(db => db.ConceptAppraisals.CountAsync()));
var view = await world.Fresh(db => world.Appraisals(db).GetSubjectAsync(Collection, "bison", null, default));
Assert.Equal("Bison Prime", view!.Title);
Assert.Equal(new ConceptTally(1, 0, new StanceCounts(0, 0)), view.Tally);
Assert.Equal(1, await world.Fresh(db => db.AuditEvents.CountAsync(x => x.Action == "Rename")));
}
[Fact]
public async Task Absent_key_is_retired_only_when_named()
{
using var world = new World();
await world.Apply(Fixture());
await world.Cast("kestrel", ConceptStance.Up);
var smaller = Fixture().Where(s => s.Key != "kestrel").ToList();
var unnamed = await world.Apply(smaller);
Assert.Equal(SubjectChange.Absent, unnamed.Entries.Single(e => e.Key == "kestrel").Change);
Assert.Null((await world.Fresh(db => db.ConceptSubjects.AsNoTracking().SingleAsync(x => x.Key == "kestrel"))).RetiredAtUtc);
Assert.Contains("kestrel", (await world.Fresh(db => world.Appraisals(db).GetTalliesAsync(Collection, default))).Keys);
var named = await world.Apply(smaller, ["kestrel"]);
Assert.Equal(SubjectChange.Retire, named.Entries.Single(e => e.Key == "kestrel").Change);
var retired = await world.Fresh(db => db.ConceptSubjects.AsNoTracking().SingleAsync(x => x.Key == "kestrel"));
Assert.NotNull(retired.RetiredAtUtc);
Assert.Equal(1, await world.Fresh(db => db.ConceptAppraisals.CountAsync()));
Assert.DoesNotContain("kestrel", (await world.Fresh(db => world.Appraisals(db).GetTalliesAsync(Collection, default))).Keys);
Assert.Equal(CastResult.RetiredSubject, await world.Cast("kestrel", ConceptStance.Down));
Assert.Equal(1, await world.Fresh(db => db.AuditEvents.CountAsync(x => x.Action == "Retire")));
}
[Theory]
[InlineData("bison")]
[InlineData("never-registered")]
public async Task Retire_naming_a_key_that_is_not_registered_live_and_absent_is_refused_whole(string named)
{
using var world = new World();
await world.Apply(Fixture());
var renamed = Fixture().Select(s => s.Key == "kestrel" ? s with { Title = "Kestrel II" } : s).ToList();
var refusal = await Assert.ThrowsAsync<ManifestRefusedException>(() => world.Apply(renamed, [named]));
Assert.Equal(("retire", "NotAbsent"), (refusal.Field, refusal.Code));
Assert.DoesNotContain(named, refusal.Message);
Assert.Equal("Kestrel", (await world.Fresh(db => db.ConceptSubjects.AsNoTracking().SingleAsync(x => x.Key == "kestrel"))).Title);
Assert.Equal(3, await world.Fresh(db => db.AuditEvents.CountAsync()));
}
[Fact]
public async Task Rerunning_the_same_retire_list_converges_and_writes_nothing()
{
using var world = new World();
await world.Apply(Fixture());
var smaller = Fixture().Where(s => s.Key == "bison").ToList();
var first = await world.Apply(smaller, ["kestrel", "ibis-mk2"]);
Assert.Equal([SubjectChange.Unchanged, SubjectChange.Retire, SubjectChange.Retire], first.Entries.Select(e => e.Change));
var before = await world.Fresh(db => db.ConceptSubjects.AsNoTracking().OrderBy(x => x.Key).ToListAsync());
var audits = await world.Fresh(db => db.AuditEvents.CountAsync());
world.Clock.Advance(TimeSpan.FromHours(1));
var second = await world.Apply(smaller, ["kestrel", "ibis-mk2"]);
Assert.Equal(
[("bison", SubjectChange.Unchanged), ("ibis-mk2", SubjectChange.Retired), ("kestrel", SubjectChange.Retired)],
second.Entries.Select(e => (e.Key, e.Change)));
Assert.Equal(audits, await world.Fresh(db => db.AuditEvents.CountAsync()));
var after = await world.Fresh(db => db.ConceptSubjects.AsNoTracking().OrderBy(x => x.Key).ToListAsync());
Assert.Equal(before.Select(x => (x.Id, x.Title, x.RetiredAtUtc)), after.Select(x => (x.Id, x.Title, x.RetiredAtUtc)));
}
[Fact]
public async Task Rerunning_a_retire_list_after_a_partial_apply_retires_the_rest()
{
using var world = new World();
await world.Apply(Fixture());
var smaller = Fixture().Where(s => s.Key == "bison").ToList();
await world.Apply(smaller, ["kestrel"]);
var plan = await world.Apply(smaller, ["kestrel", "ibis-mk2"]);
Assert.Equal(SubjectChange.Retired, plan.Entries.Single(e => e.Key == "kestrel").Change);
Assert.Equal(SubjectChange.Retire, plan.Entries.Single(e => e.Key == "ibis-mk2").Change);
var rows = await world.Fresh(db => db.ConceptSubjects.AsNoTracking().ToListAsync());
Assert.Equal(["ibis-mk2", "kestrel"], rows.Where(r => r.RetiredAtUtc is not null).Select(r => r.Key).Order());
Assert.Equal(2, await world.Fresh(db => db.AuditEvents.CountAsync(x => x.Action == "Retire")));
}
[Fact]
public async Task Naming_a_retired_key_that_the_manifest_lists_again_is_still_refused()
{
using var world = new World();
await world.Apply(Fixture());
await world.Apply(Fixture().Where(s => s.Key != "kestrel").ToList(), ["kestrel"]);
var refusal = await Assert.ThrowsAsync<ManifestRefusedException>(() => world.Apply(Fixture(), ["kestrel"]));
Assert.Equal(("retire", "NotAbsent"), (refusal.Field, refusal.Code));
Assert.NotNull((await world.Fresh(db => db.ConceptSubjects.AsNoTracking().SingleAsync(x => x.Key == "kestrel"))).RetiredAtUtc);
}
[Fact]
public async Task Re_registering_a_retired_key_revives_it()
{
using var world = new World();
await world.Apply(Fixture());
await world.Cast("kestrel", ConceptStance.Up);
await world.Apply(Fixture().Where(s => s.Key != "kestrel").ToList(), ["kestrel"]);
var plan = await world.Apply(Fixture());
Assert.Equal(SubjectChange.Revive, plan.Entries.Single(e => e.Key == "kestrel").Change);
var subject = await world.Fresh(db => db.ConceptSubjects.AsNoTracking().SingleAsync(x => x.Key == "kestrel"));
Assert.Null(subject.RetiredAtUtc);
var tallies = await world.Fresh(db => world.Appraisals(db).GetTalliesAsync(Collection, default));
Assert.Equal(new ConceptTally(1, 0, new StanceCounts(0, 0)), tallies["kestrel"]);
Assert.Equal(1, await world.Fresh(db => db.AuditEvents.CountAsync(x => x.Action == "Revive")));
}
[Theory]
[InlineData("""{"ships":[{"id":"a","name":"A"},{"id":"a","name":"B"}]}""", "ships[1].id", "Duplicate")]
[InlineData("""{"ships":[{"id":"Upper","name":"A"}]}""", "ships[0].id", "InvalidKey")]
[InlineData("""{"ships":[{"id":"a/b","name":"A"}]}""", "ships[0].id", "InvalidKey")]
[InlineData("""{"ships":[{"id":"ship-a\n","name":"A"}]}""", "ships[0].id", "InvalidKey")]
[InlineData("""{"ships":[{"id":"","name":"A"}]}""", "ships[0].id", "InvalidKey")]
[InlineData("""{"ships":[{"id":"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa","name":"A"}]}""", "ships[0].id", "InvalidKey")]
[InlineData("""{"ships":[{"id":"a"}]}""", "ships[0].name", "Missing")]
[InlineData("""{"ships":[{"id":"a","name":7}]}""", "ships[0].name", "NotString")]
[InlineData("""{"ships":[{"id":"a","name":" "}]}""", "ships[0].name", "InvalidTitle")]
[InlineData("""{"ships":[{"id":"a","name":"bad\ntitle"}]}""", "ships[0].name", "InvalidTitle")]
[InlineData("""{"ships":["a"]}""", "ships[0].id", "Missing")]
[InlineData("""{"fleet":[]}""", "ships", "Missing")]
[InlineData("[]", "ships", "Missing")]
[InlineData("""{"ships":5}""", "ships", "Missing")]
[InlineData("not json", "manifest", "NotJson")]
public async Task Malformed_manifest_is_refused_whole(string json, string field, string code)
{
using var world = new World();
var refusal = Assert.Throws<ManifestRefusedException>(() => Parse(json));
Assert.Equal((field, code), (refusal.Field, refusal.Code));
Assert.Equal($"{field}: {code}", refusal.Message);
Assert.Empty(await world.Fresh(db => db.ConceptSubjects.ToListAsync()));
}
[Fact]
public void A_title_of_exactly_the_column_width_is_accepted_and_one_more_is_refused()
{
var fits = new string('t', ConceptSubjectRegistry.MaxTitleLength);
Assert.Equal(fits, Parse($$"""{"ships":[{"id":"a","name":"{{fits}}"}]}""").Single().Title);
var refusal = Assert.Throws<ManifestRefusedException>(() =>
Parse($$"""{"ships":[{"id":"a","name":"{{fits}}x"}]}"""));
Assert.Equal(("ships[0].name", "TooLong"), (refusal.Field, refusal.Code));
}
private static readonly JsonSerializerOptions Raw = new() { Encoder = JavaScriptEncoder.UnsafeRelaxedJsonEscaping };
[Theory]
[InlineData("\u202Eevil")]
[InlineData("a\u202Ab")]
[InlineData("a\u2066b")]
[InlineData("a\u2069b")]
[InlineData("a\u061Cb")]
[InlineData("a\u200Eb")]
[InlineData("a\u200Fb")]
[InlineData("a\u2028b")]
[InlineData("a\u2029b")]
[InlineData("a\u200Bb")]
[InlineData("a\u200Cb")]
[InlineData("a\u200Db")]
[InlineData("a\u2060b")]
[InlineData("a\uFEFFb")]
[InlineData("a\u0085b")]
[InlineData("a\tb")]
[InlineData(" Ibis")]
[InlineData("Ibis ")]
[InlineData("\u00A0Ibis")]
[InlineData("Ibis\u00A0")]
[InlineData("\u3000Ibis")]
[InlineData("!!!")]
public void A_title_with_a_format_character_edge_whitespace_or_no_letter_is_refused(string title)
{
var json = """{"ships":[{"id":"a","name":""" + JsonSerializer.Serialize(title, Raw) + "}]}";
var refusal = Assert.Throws<ManifestRefusedException>(() => Parse(json));
Assert.Equal(("ships[0].name", "InvalidTitle"), (refusal.Field, refusal.Code));
}
[Theory]
[InlineData("Ibis Mk II")]
[InlineData("Ünïcode Ship")]
[InlineData("Ship ★ 7")]
[InlineData("7")]
[InlineData("Kestrel-2: the \"Bold\"")]
public void An_ordinary_title_is_accepted(string title)
{
var json = """{"ships":[{"id":"a","name":""" + JsonSerializer.Serialize(title, Raw) + "}]}";
Assert.Equal(title, Parse(json).Single().Title);
}
[Theory]
[InlineData("""{"ships":[{"id":"a","name":"x\ud800y"}]}""", "ships[0].name", "BadText")]
[InlineData("""{"ships":[{"id":"a\udc00","name":"x"}]}""", "ships[0].id", "BadText")]
[InlineData("""{"ships":[{"id":"a","name":"x","na\ud800me":"y"}]}""", "manifest", "BadText")]
public void A_lone_surrogate_is_refused_not_thrown(string json, string field, string code)
{
var refusal = Assert.Throws<ManifestRefusedException>(() => Parse(json));
Assert.Equal((field, code), (refusal.Field, refusal.Code));
}
[Fact]
public void Malformed_utf8_anywhere_in_the_file_is_refused()
{
byte[] inName = [.. "{\"ships\":[{\"id\":\"a\",\"name\":\"X"u8, 0xC3, 0x28, .. "\"}]}"u8];
byte[] inUnreadValue = [.. "{\"ships\":[],\"x\":\"X"u8, 0xFF, .. "\"}"u8];
foreach (var bytes in new[] { inName, inUnreadValue })
{
var refusal = Assert.Throws<ManifestRefusedException>(() => ConceptSubjectRegistry.ParseManifest(bytes));
Assert.Equal(("manifest", "NotUtf8"), (refusal.Field, refusal.Code));
}
}
[Theory]
[InlineData("""{"ships":[{"id":"a","id":"b","name":"A"}]}""")]
[InlineData("""{"ships":[{"id":"a","name":"A","name":"B"}]}""")]
[InlineData("""{"ships":[],"ships":[]}""")]
[InlineData("""{"ships":[{"id":"a","name":"A"}],"votes":{"x":1,"x":2}}""")]
[InlineData("""{"ships":[{"id":"a","name":"A","meta":{"k":[{"z":1,"z":1}]}}]}""")]
[InlineData("""{"ships":[{"id":"a","name":"A","id":"b"}]}""")]
public void A_duplicate_json_property_is_refused_wherever_it_sits(string json)
{
var refusal = Assert.Throws<ManifestRefusedException>(() => Parse(json));
Assert.Equal(("manifest", "DuplicateProperty"), (refusal.Field, refusal.Code));
}
[Fact]
public void The_same_property_name_in_sibling_and_nested_objects_is_not_a_duplicate()
{
var ships = Parse("""{"id":"top","ships":[{"id":"a","name":"A","meta":{"id":"x","name":"y"}},{"id":"b","name":"B"}]}""");
Assert.Equal(["a", "b"], ships.Select(s => s.Key));
}
[Fact]
public void A_manifest_of_exactly_the_byte_bound_is_accepted_and_one_more_byte_is_refused()
{
const string head = """{"ships":[{"id":"a","name":"A"}]}""";
var fits = Encoding.UTF8.GetBytes(head + new string(' ', ConceptSubjectRegistry.MaxManifestBytes - head.Length));
Assert.Equal(ConceptSubjectRegistry.MaxManifestBytes, fits.Length);
Assert.Single(ConceptSubjectRegistry.ParseManifest(fits));
var refusal = Assert.Throws<ManifestRefusedException>(() => ConceptSubjectRegistry.ParseManifest(fits.Append((byte)' ').ToArray()));
Assert.Equal(("manifest", "TooLarge"), (refusal.Field, refusal.Code));
}
[Fact]
public void A_manifest_of_exactly_the_ship_bound_is_accepted_and_one_more_ship_is_refused()
{
static string Fleet(int count) =>
"""{"ships":[""" + string.Join(",", Enumerable.Range(0, count).Select(i => $$"""{"id":"s{{i}}","name":"S{{i}}"}""")) + "]}";
Assert.Equal(ConceptSubjectRegistry.MaxShips, Parse(Fleet(ConceptSubjectRegistry.MaxShips)).Count);
var refusal = Assert.Throws<ManifestRefusedException>(() => Parse(Fleet(ConceptSubjectRegistry.MaxShips + 1)));
Assert.Equal(("ships", "TooMany"), (refusal.Field, refusal.Code));
}
[Fact]
public async Task Absent_keys_are_planned_in_key_order()
{
using var world = new World();
await world.Apply(Fixture());
var plan = await world.Plan([new ManifestShip("zzz-new", "New")]);
Assert.Equal(["zzz-new", "bison", "ibis-mk2", "kestrel"], plan.Entries.Select(e => e.Key));
}
[Fact]
public async Task A_bad_entry_after_good_ones_refuses_the_file_and_writes_no_row()
{
using var world = new World();
var json = """{"ships":[{"id":"good","name":"Good"},{"id":"ship-a\n","name":"Bad"}]}""";
Assert.Throws<ManifestRefusedException>(() => Parse(json));
Assert.Empty(await world.Fresh(db => db.ConceptSubjects.ToListAsync()));
}
[Fact]
public async Task Refusal_never_echoes_the_offending_value()
{
const string canary = "CANARY-secret/value";
var json = $$"""{"ships":[{"id":"{{canary}}","name":"A"}]}""";
var refusal = Assert.Throws<ManifestRefusedException>(() => Parse(json));
Assert.DoesNotContain("CANARY", refusal.Message);
using var world = new World();
var collection = await Assert.ThrowsAsync<ManifestRefusedException>(() => world.Registry(world.NewDb()).PlanAsync(canary, [], None, default));
Assert.DoesNotContain("CANARY", collection.Message);
Assert.Equal(("collection", "InvalidKey"), (collection.Field, collection.Code));
var retire = await Assert.ThrowsAsync<ManifestRefusedException>(() => world.Registry(world.NewDb()).PlanAsync(Collection, [], [canary], default));
Assert.DoesNotContain("CANARY", retire.Message);
Assert.Equal(("retire", "InvalidKey"), (retire.Field, retire.Code));
}
[Theory]
[InlineData("ship-a\n")]
[InlineData("ship-a\r\n")]
[InlineData("ship-a ")]
public async Task The_key_grammar_rejects_a_trailing_line_feed_wherever_it_is_reused(string forged)
{
using var world = new World();
await world.Apply(Fixture());
await world.Registry(world.NewDb()).ApplyAsync(Collection, [new ManifestShip("ship-a", "A")], None, default);
Assert.False(ConceptKey.IsValid(forged));
Assert.False(ConceptKey.IsValid("key\n"));
Assert.True(ConceptKey.IsValid("ship-a"));
Assert.Equal(CastResult.UnknownSubject, await world.Cast(forged, ConceptStance.Up));
Assert.Null(await world.Fresh(db => world.Appraisals(db).GetSubjectAsync(Collection, forged, null, default)));
await Assert.ThrowsAsync<ManifestRefusedException>(() =>
world.Registry(world.NewDb()).PlanAsync(Collection + "\n", [], None, default));
await Assert.ThrowsAsync<ManifestRefusedException>(() =>
world.Registry(world.NewDb()).PlanAsync(Collection, [], ["ship-a\n"], default));
Assert.Empty(await world.Fresh(db => db.ConceptAppraisals.ToListAsync()));
}
[Fact]
public async Task Host_command_plans_and_applies_with_exit_codes()
{
using var world = new World();
var services = world.Services();
var plan = new StringWriter();
Assert.Equal(ConceptCommand.Ok, await ConceptCommand.RunAsync(["plan", Collection, FixturePath], services, plan));
Assert.Contains("add bison", plan.ToString());
Assert.Contains("add ibis-mk2", plan.ToString());
Assert.Empty(await world.Fresh(db => db.ConceptSubjects.ToListAsync()));
Assert.Empty(await world.Fresh(db => db.AuditEvents.ToListAsync()));
var apply = new StringWriter();
Assert.Equal(ConceptCommand.Ok, await ConceptCommand.RunAsync(["apply", Collection, FixturePath], services, apply));
Assert.Equal(3, await world.Fresh(db => db.ConceptSubjects.CountAsync()));
var again = new StringWriter();
Assert.Equal(ConceptCommand.Ok, await ConceptCommand.RunAsync(["apply", Collection, FixturePath], services, again));
Assert.Contains("unchanged bison", again.ToString());
Assert.Equal(3, await world.Fresh(db => db.AuditEvents.CountAsync()));
}
[Fact]
public async Task Host_command_retire_flag_retires_the_named_absent_key()
{
using var world = new World();
var services = world.Services();
await ConceptCommand.RunAsync(["apply", Collection, FixturePath], services, new StringWriter());
var smaller = Path.Combine(world.Dir, "two.json");
await File.WriteAllTextAsync(smaller, """{"ships":[{"id":"bison","name":"Bison"},{"id":"ibis-mk2","name":"Ibis Mk II"}]}""");
var output = new StringWriter();
Assert.Equal(ConceptCommand.Ok, await ConceptCommand.RunAsync(["apply", Collection, smaller, "--retire", "kestrel"], services, output));
Assert.Contains("retire kestrel", output.ToString());
Assert.NotNull((await world.Fresh(db => db.ConceptSubjects.AsNoTracking().SingleAsync(x => x.Key == "kestrel"))).RetiredAtUtc);
}
[Fact]
public async Task Host_command_exits_non_zero_on_refusal_and_never_echoes_input()
{
using var world = new World();
var services = world.Services();
var bad = Path.Combine(world.Dir, "bad.json");
await File.WriteAllTextAsync(bad, """{"ships":[{"id":"CANARY/Id","name":"A"}]}""");
var refused = new StringWriter();
Assert.Equal(ConceptCommand.Refused, await ConceptCommand.RunAsync(["apply", Collection, bad], services, refused));
Assert.Contains("ships[0].id", refused.ToString());
Assert.DoesNotContain("CANARY", refused.ToString());
var missing = new StringWriter();
Assert.Equal(ConceptCommand.Refused, await ConceptCommand.RunAsync(["apply", Collection, Path.Combine(world.Dir, "CANARY-missing.json")], services, missing));
Assert.DoesNotContain("CANARY", missing.ToString());
Assert.Equal("refused manifest Unreadable", missing.ToString().Trim());
Assert.Empty(await world.Fresh(db => db.ConceptSubjects.ToListAsync()));
}
[Theory]
[InlineData("frobnicate", "c", "m")]
[InlineData("plan", "c")]
[InlineData("apply", "c", "m", "--bogus", "x")]
[InlineData("apply", "c", "m", "--retire")]
public async Task Host_command_unknown_subcommand_or_shape_exits_2(params string[] args)
{
using var world = new World();
var output = new StringWriter();
Assert.Equal(ConceptCommand.Usage, await ConceptCommand.RunAsync(args, world.Services(), output));
Assert.StartsWith("usage:", output.ToString());
Assert.Empty(await world.Fresh(db => db.ConceptSubjects.ToListAsync()));
Assert.Equal(ConceptCommand.Usage, await ConceptCommand.RunAsync([], world.Services(), new StringWriter()));
}
[Theory]
[InlineData("")]
[InlineData("CANARY\0path.json")]
public async Task Host_command_refuses_an_empty_or_nul_path(string path)
{
using var world = new World();
var output = new StringWriter();
Assert.Equal(ConceptCommand.Refused, await ConceptCommand.RunAsync(["plan", Collection, path], world.Services(), output));
Assert.Equal("refused manifest Unreadable", output.ToString().Trim());
}
[Fact]
public async Task Host_command_refuses_a_directory_and_an_oversized_file()
{
using var world = new World();
var big = Path.Combine(world.Dir, "big.json");
await File.WriteAllBytesAsync(big, new byte[ConceptSubjectRegistry.MaxManifestBytes + 1]);
var dir = new StringWriter();
Assert.Equal(ConceptCommand.Refused, await ConceptCommand.RunAsync(["plan", Collection, world.Dir], world.Services(), dir));
Assert.Equal("refused manifest Unreadable", dir.ToString().Trim());
var large = new StringWriter();
Assert.Equal(ConceptCommand.Refused, await ConceptCommand.RunAsync(["plan", Collection, big], world.Services(), large));
Assert.Equal("refused manifest TooLarge", large.ToString().Trim());
}
[Fact]
public async Task Host_command_refuses_a_manifest_that_is_not_utf8_text()
{
using var world = new World();
var path = Path.Combine(world.Dir, "latin1.json");
await File.WriteAllBytesAsync(path, [.. "{\"ships\":[{\"id\":\"a\",\"name\":\"CANARY-"u8, 0xE9, .. "\"}]}"u8]);
var output = new StringWriter();
Assert.Equal(ConceptCommand.Refused, await ConceptCommand.RunAsync(["plan", Collection, path], world.Services(), output));
Assert.Equal("refused manifest NotUtf8", output.ToString().Trim());
}
[Fact]
public async Task Built_command_exits_with_its_documented_codes_and_echoes_no_input()
{
using var world = new World();
var big = Path.Combine(world.Dir, "CANARYBIG.json");
await File.WriteAllBytesAsync(big, new byte[ConceptSubjectRegistry.MaxManifestBytes + 1]);
var bidi = Path.Combine(world.Dir, "CANARYBIDI.json");
await File.WriteAllTextAsync(bidi, """{"ships":[{"id":"a","name":"CANARYTITLE\u202E"}]}""");
var dup = Path.Combine(world.Dir, "CANARYDUP.json");
await File.WriteAllTextAsync(dup, """{"ships":[{"id":"CANARYID","id":"b","name":"A"}]}""");
var surrogate = Path.Combine(world.Dir, "CANARYSURR.json");
await File.WriteAllTextAsync(surrogate, """{"ships":[{"id":"a","name":"CANARYNAME\ud800"}]}""");
var latin1 = Path.Combine(world.Dir, "CANARYLATIN.json");
await File.WriteAllBytesAsync(latin1, [.. "{\"ships\":[{\"id\":\"a\",\"name\":\"CANARYBYTES"u8, 0xE9, .. "\"}]}"u8]);
var cases = new (string[] Args, int Code)[]
{
(["plan", Collection, ""], ConceptCommand.Refused),
(["plan", Collection, Path.Combine(world.Dir, "CANARYMISSING.json")], ConceptCommand.Refused),
(["plan", Collection, world.Dir], ConceptCommand.Refused),
(["plan", Collection, big], ConceptCommand.Refused),
(["plan", Collection, bidi], ConceptCommand.Refused),
(["apply", Collection, dup], ConceptCommand.Refused),
(["apply", Collection, surrogate], ConceptCommand.Refused),
(["plan", Collection, latin1], ConceptCommand.Refused),
(["plan", "CANARYCOLLECTION", bidi], ConceptCommand.Refused),
(["frob", "CANARYARG", "x"], ConceptCommand.Usage),
(["plan"], ConceptCommand.Usage),
};
foreach (var (args, code) in cases)
{
var result = await RunBuiltCommandAsync(args);
Assert.True(code == result.ExitCode, $"case {args[0]}/{args.Length}: exit {result.ExitCode}, stdout {result.StdOut}, stderr {result.StdErr}");
Assert.DoesNotContain("CANARY", result.StdOut + result.StdErr);
Assert.DoesNotContain("Unhandled exception", result.StdOut + result.StdErr);
}
}
private static async Task<(int ExitCode, string StdOut, string StdErr)> RunBuiltCommandAsync(string[] args)
{
var dll = Path.Combine(AppContext.BaseDirectory, "Bifrost.Web.dll");
Assert.True(File.Exists(Path.ChangeExtension(dll, ".runtimeconfig.json")), "Bifrost.Web.runtimeconfig.json is not beside the test assembly");
var start = new ProcessStartInfo("dotnet")
{
RedirectStandardOutput = true,
RedirectStandardError = true,
UseShellExecute = false
};
foreach (var part in new[] { dll, "concepts" }.Concat(args))
{
start.ArgumentList.Add(part);
}
// Every case refuses before the database is touched; the password is a canary for leaks.
start.Environment["ConnectionStrings__Bifrost"] = "Host=127.0.0.1;Port=1;Database=x;Username=u;Password=CANARYPW";
using var process = Process.Start(start)!;
var stdout = process.StandardOutput.ReadToEndAsync();
var stderr = process.StandardError.ReadToEndAsync();
using var timeout = new CancellationTokenSource(TimeSpan.FromSeconds(120));
await process.WaitForExitAsync(timeout.Token);
return (process.ExitCode, await stdout, await stderr);
}
private sealed class World : IDisposable
{
private readonly string _dbName = $"registry-{Guid.NewGuid():N}";
public World()
{
Dir = Path.Combine(Path.GetTempPath(), $"registry-{Guid.NewGuid():N}");
Directory.CreateDirectory(Dir);
}
public string Dir { get; }
public ManualClock Clock { get; } = new(new DateTimeOffset(2026, 10, 10, 12, 0, 0, TimeSpan.Zero));
public BifrostDbContext NewDb() =>
new(new DbContextOptionsBuilder<BifrostDbContext>().UseInMemoryDatabase(_dbName).Options);
public ConceptSubjectRegistry Registry(BifrostDbContext db) =>
new(db, new AuditTrailService(db, Clock), Clock);
public ConceptAppraisalService Appraisals(BifrostDbContext db) =>
new(db, new AuditTrailService(db, Clock), Clock,
Options.Create(new ConceptVoteOptions { NetworkTagSecret = "test-tag-secret" }));
public async Task<SubjectPlan> Apply(IReadOnlyList<ManifestShip> ships, string[]? retire = null)
{
await using var db = NewDb();
return await Registry(db).ApplyAsync(Collection, ships, retire ?? None, default);
}
public async Task<SubjectPlan> Plan(IReadOnlyList<ManifestShip> ships)
{
await using var db = NewDb();
return await Registry(db).PlanAsync(Collection, ships, None, default);
}
public async Task<CastResult> Cast(string key, ConceptStance stance)
{
await using var db = NewDb();
var voter = VoterIdentity.For(CurrentBifrostActor.Anonymous, "device-" + stance)!;
return await Appraisals(db).CastAsync(Collection, key, voter, stance, IPAddress.Parse("203.0.113.7"), default);
}
public async Task<T> Fresh<T>(Func<BifrostDbContext, Task<T>> read)
{
await using var db = NewDb();
return await read(db);
}
public IServiceProvider Services()
{
var services = new ServiceCollection();
services.AddDbContext<BifrostDbContext>(o => o.UseInMemoryDatabase(_dbName));
services.AddSingleton<TimeProvider>(Clock);
services.AddScoped<AuditTrailService>();
services.AddScoped<ConceptSubjectRegistry>();
return services.BuildServiceProvider();
}
public void Dispose() => Directory.Delete(Dir, true);
}
private sealed class ManualClock(DateTimeOffset start) : TimeProvider
{
private DateTimeOffset _now = start;
public override DateTimeOffset GetUtcNow() => _now;
public void Advance(TimeSpan by) => _now += by;
}
}

View file

@ -0,0 +1,9 @@
{
"sections": [],
"votes": {},
"ships": [
{ "id": "bison", "name": "Bison", "code": "AU", "role": "Swarm escort" },
{ "id": "ibis-mk2", "name": "Ibis Mk II", "code": "AU" },
{ "id": "kestrel", "name": "Kestrel" }
]
}