diff --git a/src/Bifrost.Web/Features/ConceptVotes/ConceptAppraisalService.cs b/src/Bifrost.Web/Features/ConceptVotes/ConceptAppraisalService.cs index 11893ad..9e0132b 100644 --- a/src/Bifrost.Web/Features/ConceptVotes/ConceptAppraisalService.cs +++ b/src/Bifrost.Web/Features/ConceptVotes/ConceptAppraisalService.cs @@ -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 CastAsync( string collection, string key, @@ -260,7 +257,7 @@ public sealed class ConceptAppraisalService( } private Task 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(null); diff --git a/src/Bifrost.Web/Features/ConceptVotes/ConceptCommand.cs b/src/Bifrost.Web/Features/ConceptVotes/ConceptCommand.cs new file mode 100644 index 0000000..f81d1c0 --- /dev/null +++ b/src/Bifrost.Web/Features/ConceptVotes/ConceptCommand.cs @@ -0,0 +1,70 @@ +using Microsoft.Extensions.DependencyInjection; + +namespace Bifrost.Web.Features.ConceptVotes; + +// Host command: Bifrost.Web.dll concepts plan|apply [--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 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 [--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(); + 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 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"); + } + } +} diff --git a/src/Bifrost.Web/Features/ConceptVotes/ConceptKey.cs b/src/Bifrost.Web/Features/ConceptVotes/ConceptKey.cs new file mode 100644 index 0000000..2be54e9 --- /dev/null +++ b/src/Bifrost.Web/Features/ConceptVotes/ConceptKey.cs @@ -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); +} diff --git a/src/Bifrost.Web/Features/ConceptVotes/ConceptSubjectRegistry.cs b/src/Bifrost.Web/Features/ConceptVotes/ConceptSubjectRegistry.cs new file mode 100644 index 0000000..246926f --- /dev/null +++ b/src/Bifrost.Web/Features/ConceptVotes/ConceptSubjectRegistry.cs @@ -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 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 ParseManifest(ReadOnlyMemory 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(); + var seen = new HashSet(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 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 utf8) + { + var reader = new Utf8JsonReader(utf8); + var scopes = new Stack?>(); + while (reader.Read()) + { + switch (reader.TokenType) + { + case JsonTokenType.StartObject: + scopes.Push(new HashSet(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 PlanAsync( + string collection, + IReadOnlyList ships, + IReadOnlyCollection retire, + CancellationToken cancellationToken) + { + if (!ConceptKey.IsValid(collection)) + { + throw new ManifestRefusedException("collection", "InvalidKey"); + } + + var named = new HashSet(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(); + 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 ApplyAsync( + string collection, + IReadOnlyList ships, + IReadOnlyCollection 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"); + } + } +} diff --git a/src/Bifrost.Web/Program.cs b/src/Bifrost.Web/Program.cs index bea0beb..dbdc558 100644 --- a/src/Bifrost.Web/Program.cs +++ b/src/Bifrost.Web/Program.cs @@ -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(options => options.UseNpgsql(BifrostConnectionString(host.Configuration))); + host.Services.AddSingleton(TimeProvider.System); + host.Services.AddScoped(); + host.Services.AddScoped(); + 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 @@ -70,10 +87,7 @@ builder.Services.ConfigureHttpJsonOptions(options => builder.Services.AddDbContext(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 => @@ -103,6 +117,7 @@ builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); +builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddSingleton(); diff --git a/tests/Bifrost.Web.Tests/Bifrost.Web.Tests.csproj b/tests/Bifrost.Web.Tests/Bifrost.Web.Tests.csproj index 355d11c..77670c1 100644 --- a/tests/Bifrost.Web.Tests/Bifrost.Web.Tests.csproj +++ b/tests/Bifrost.Web.Tests/Bifrost.Web.Tests.csproj @@ -20,6 +20,10 @@ + + + + diff --git a/tests/Bifrost.Web.Tests/ConceptSubjectRegistryTests.cs b/tests/Bifrost.Web.Tests/ConceptSubjectRegistryTests.cs new file mode 100644 index 0000000..c942457 --- /dev/null +++ b/tests/Bifrost.Web.Tests/ConceptSubjectRegistryTests.cs @@ -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 Parse(string json) => + ConceptSubjectRegistry.ParseManifest(Encoding.UTF8.GetBytes(json)); + + private static IReadOnlyList 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(() => 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(() => 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(() => 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(() => + 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(() => 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(() => 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(() => 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(() => 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(() => 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(() => 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(() => 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(() => Parse(json)); + Assert.DoesNotContain("CANARY", refusal.Message); + using var world = new World(); + var collection = await Assert.ThrowsAsync(() => 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(() => 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(() => + world.Registry(world.NewDb()).PlanAsync(Collection + "\n", [], None, default)); + await Assert.ThrowsAsync(() => + 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().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 Apply(IReadOnlyList ships, string[]? retire = null) + { + await using var db = NewDb(); + return await Registry(db).ApplyAsync(Collection, ships, retire ?? None, default); + } + + public async Task Plan(IReadOnlyList ships) + { + await using var db = NewDb(); + return await Registry(db).PlanAsync(Collection, ships, None, default); + } + + public async Task 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 Fresh(Func> read) + { + await using var db = NewDb(); + return await read(db); + } + + public IServiceProvider Services() + { + var services = new ServiceCollection(); + services.AddDbContext(o => o.UseInMemoryDatabase(_dbName)); + services.AddSingleton(Clock); + services.AddScoped(); + services.AddScoped(); + 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; + } +} diff --git a/tests/Bifrost.Web.Tests/Fixtures/mill-three-ships.json b/tests/Bifrost.Web.Tests/Fixtures/mill-three-ships.json new file mode 100644 index 0000000..bde47d0 --- /dev/null +++ b/tests/Bifrost.Web.Tests/Fixtures/mill-three-ships.json @@ -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" } + ] +}