Merge hands/mill-votes-cut-3: concept subject registration from the Mill manifest
Held by concept-mill-votes:verdict:cut-3.s2. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
commit
72741198fa
8 changed files with 1141 additions and 8 deletions
|
|
@ -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);
|
||||
|
|
|
|||
70
src/Bifrost.Web/Features/ConceptVotes/ConceptCommand.cs
Normal file
70
src/Bifrost.Web/Features/ConceptVotes/ConceptCommand.cs
Normal 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");
|
||||
}
|
||||
}
|
||||
}
|
||||
13
src/Bifrost.Web/Features/ConceptVotes/ConceptKey.cs
Normal file
13
src/Bifrost.Web/Features/ConceptVotes/ConceptKey.cs
Normal 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);
|
||||
}
|
||||
352
src/Bifrost.Web/Features/ConceptVotes/ConceptSubjectRegistry.cs
Normal file
352
src/Bifrost.Web/Features/ConceptVotes/ConceptSubjectRegistry.cs
Normal 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");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
@ -70,10 +87,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 =>
|
||||
|
|
@ -103,6 +117,7 @@ builder.Services.AddScoped<PatronageService>();
|
|||
builder.Services.AddScoped<StripeWebhookService>();
|
||||
builder.Services.AddScoped<HeimdallPatronSupportIntakeService>();
|
||||
builder.Services.AddScoped<ConceptAppraisalService>();
|
||||
builder.Services.AddScoped<ConceptSubjectRegistry>();
|
||||
builder.Services.AddScoped<ReadinessService>();
|
||||
builder.Services.AddScoped<ApplicationBootstrapper>();
|
||||
builder.Services.AddSingleton<StartupConfigurationValidator>();
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
673
tests/Bifrost.Web.Tests/ConceptSubjectRegistryTests.cs
Normal file
673
tests/Bifrost.Web.Tests/ConceptSubjectRegistryTests.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
9
tests/Bifrost.Web.Tests/Fixtures/mill-three-ships.json
Normal file
9
tests/Bifrost.Web.Tests/Fixtures/mill-three-ships.json
Normal 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" }
|
||||
]
|
||||
}
|
||||
Loading…
Reference in a new issue