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..6ff5567 --- /dev/null +++ b/src/Bifrost.Web/Features/ConceptVotes/ConceptCommand.cs @@ -0,0 +1,56 @@ +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 + { + string json; + try + { + json = await File.ReadAllTextAsync(args[2]); + } + catch (Exception ex) when (ex is IOException or UnauthorizedAccessException) + { + throw new ManifestRefusedException("manifest", "Unreadable"); + } + + var ships = ConceptSubjectRegistry.ParseManifest(json); + 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; + } + } +} 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..ba4d171 --- /dev/null +++ b/src/Bifrost.Web/Features/ConceptVotes/ConceptSubjectRegistry.cs @@ -0,0 +1,225 @@ +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 +} + +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; + + // The Mill manifest is JSON from outside the Verse. Only ships[].id and ships[].name are read. + public static IReadOnlyList ParseManifest(string json) + { + JsonDocument document; + try + { + document = JsonDocument.Parse(json); + } + catch (JsonException) + { + throw new ManifestRefusedException("manifest", "NotJson"); + } + + using (document) + { + if (document.RootElement.ValueKind != JsonValueKind.Object || + !document.RootElement.TryGetProperty("ships", out var ships) || + ships.ValueKind != JsonValueKind.Array) + { + throw new ManifestRefusedException("ships", "Missing"); + } + + 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 (string.IsNullOrWhiteSpace(title) || title.Any(char.IsControl)) + { + throw new ManifestRefusedException($"{at}.name", "InvalidTitle"); + } + + if (title.Length > MaxTitleLength) + { + throw new ManifestRefusedException($"{at}.name", "TooLong"); + } + + result.Add(new ManifestShip(key, title)); + } + + return result; + } + } + + 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)); + } + + // Whatever is still named is not a registered, live 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) + { + 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 || !ship.TryGetProperty(name, out var value)) + { + throw new ManifestRefusedException($"{at}.{name}", "Missing"); + } + + return value.ValueKind == JsonValueKind.String + ? value.GetString()! + : throw new ManifestRefusedException($"{at}.{name}", "NotString"); + } +} diff --git a/src/Bifrost.Web/Program.cs b/src/Bifrost.Web/Program.cs index bec0271..8376009 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..cc26700 --- /dev/null +++ b/tests/Bifrost.Web.Tests/ConceptSubjectRegistryTests.cs @@ -0,0 +1,374 @@ +using System.Net; +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 Fixture() => + ConceptSubjectRegistry.ParseManifest(File.ReadAllText(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 Naming_an_already_retired_key_is_refused() + { + using var world = new World(); + await world.Apply(Fixture()); + var smaller = Fixture().Where(s => s.Key != "kestrel").ToList(); + await world.Apply(smaller, ["kestrel"]); + + await Assert.ThrowsAsync(() => world.Apply(smaller, ["kestrel"])); + } + + [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("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(() => ConceptSubjectRegistry.ParseManifest(json)); + + Assert.Equal((field, code), (refusal.Field, refusal.Code)); + Assert.Empty(await world.Fresh(db => db.ConceptSubjects.ToListAsync())); + } + + [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(() => ConceptSubjectRegistry.ParseManifest(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(() => ConceptSubjectRegistry.ParseManifest(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); + var retire = await Assert.ThrowsAsync(() => world.Registry(world.NewDb()).PlanAsync(Collection, [], [canary], default)); + Assert.DoesNotContain("CANARY", retire.Message); + } + + [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.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())); + } + + 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-" + key + 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" } + ] +}