From f69a2ec20b09d99ec3d7da499799e615d00597c0 Mon Sep 17 00:00:00 2001 From: Heimdall Date: Tue, 10 Nov 2026 21:00:35 +0000 Subject: [PATCH] Mill votes: a ranked, paged read beside the tally map, with a not-yet-voted-by-this-network filter Co-Authored-By: Claude Sonnet 5.5 --- docs/concept-mill-votes-target.md | 1 + .../ConceptVotes/ConceptAppraisalService.cs | 79 +++ .../ConceptVotes/ConceptTallyExport.cs | 85 +++ .../Features/ConceptVotes/RankCursor.cs | 62 +++ src/Bifrost.Web/Program.cs | 1 + .../ConceptRankedExportTests.cs | 519 ++++++++++++++++++ 6 files changed, 747 insertions(+) create mode 100644 src/Bifrost.Web/Features/ConceptVotes/RankCursor.cs create mode 100644 tests/Bifrost.Web.Tests/ConceptRankedExportTests.cs diff --git a/docs/concept-mill-votes-target.md b/docs/concept-mill-votes-target.md index 91c92e6..c20525d 100644 --- a/docs/concept-mill-votes-target.md +++ b/docs/concept-mill-votes-target.md @@ -85,6 +85,7 @@ Derived from them, not asked separately: - **Network prefix.** Owner: one function, `NetworkPrefix`, mapping a client address to its configured IPv4/IPv6 prefix (IPv4-mapped IPv6 folded to IPv4). The audit `NetworkTag` (HMAC-SHA256 of the prefix under a configured secret) and the rate-limit partition both read it; no second prefix computation exists. - **Tallies.** Derived only: per live subject, per stance, all and members, computed from rows and never stored as counters. - **Public tally export.** The JSON xenos boundary: anonymous `GET /public/concepts/{collection}/tallies` returning `{ key: { up, down, members: { up, down } } }` for every live subject; no voter keys, account ids or tags; CORS for the configured origins only (`https://aetheria.gamecult.org`), `GET`, no credentials; `Cache-Control: public, max-age=60`. It owns nothing. +- **Ranked read.** Beside the map, anonymous `GET /public/concepts/{collection}/ranked?sort=net|total|newest&limit=1..100&cursor=&unvoted=network` returns `{ items: [{ key, title, up, down, members }], next_cursor }`, largest first, ties by key. The cursor is opaque, keyset and bound to its sort and filter; any other query shape or cursor is a 400. `unvoted=network` omits the concepts the caller's own network tag has voted on (tag derived from the connection, never accepted or returned) and is never cached. Same CORS and Vary policy as the map; the map is unchanged. - **The Mill page.** Read only. Its existing vote slot (fact 28) fills from the export and links to `https://bifrost.gamecult.org/mill/aetheria-concept-mill/`. Voting happens on Bifrost's own origin, which keeps the device cookie first-party. - **Client address.** `UseForwardedHeaders` trusting loopback only makes `RemoteIpAddress` the real client. It is a rate-limit bucket and an audit tag, never a vote key. - **Key ring.** Data Protection keys live at a configured path with a fixed application name, so tokens survive restarts and deploys; production refuses to start without the path. diff --git a/src/Bifrost.Web/Features/ConceptVotes/ConceptAppraisalService.cs b/src/Bifrost.Web/Features/ConceptVotes/ConceptAppraisalService.cs index 9e0132b..ac98f29 100644 --- a/src/Bifrost.Web/Features/ConceptVotes/ConceptAppraisalService.cs +++ b/src/Bifrost.Web/Features/ConceptVotes/ConceptAppraisalService.cs @@ -23,6 +23,18 @@ public sealed record StanceCounts(int Up, int Down); public sealed record ConceptTally(int Up, int Down, StanceCounts Members); +public enum RankOrder +{ + Net, + Total, + Newest +} + +// One row of the ranked read: counts and the subject's title only, never a voter key, account id or network tag. +public sealed record RankedSubject(string Key, string Title, ConceptTally Tally); + +public sealed record RankedPage(IReadOnlyList Items, string? NextCursor); + public sealed record ConceptSubjectView( string Collection, string Key, @@ -189,6 +201,73 @@ public sealed class ConceptAppraisalService( StringComparer.Ordinal); } + // The ranked read: live subjects of one collection ordered by net (up minus down), total (up plus down) or + // registration time, largest first, ties by key ascending, so the order is total and a cursor names a position. + // The cursor is keyset: the next page is whatever sorts after the last row's (value, key), so a vote landing + // between pages can move rows but never makes a page repeat the cursor's own row. With a network tag, + // subjects whose stored tag equals it are left out ("not yet voted by this network"); the tag is only compared + // inside the query and is never returned. Null when the collection has no live subject. + public async Task GetRankedAsync( + string collection, + RankOrder order, + int limit, + RankCursor? after, + IPAddress? unvotedBy, + CancellationToken cancellationToken) + { + var live = await dbContext.ConceptSubjects.AsNoTracking() + .Where(x => x.Collection == collection && x.RetiredAtUtc == null) + .Select(x => new { x.Id, x.Key, x.Title, x.RegisteredAtUtc }) + .ToListAsync(cancellationToken); + if (live.Count == 0) + { + return null; + } + + var ids = live.Select(x => x.Id).ToList(); + var groups = await dbContext.ConceptAppraisals.AsNoTracking() + .Where(x => ids.Contains(x.SubjectId)) + .GroupBy(x => new { x.SubjectId, x.Stance, IsMember = x.VoterClass == VoterClass.Member }) + .Select(g => new { g.Key.SubjectId, g.Key.Stance, g.Key.IsMember, Count = g.Count() }) + .ToListAsync(cancellationToken); + + var voted = new HashSet(); + if (unvotedBy is not null) + { + var opts = options.Value; + var tag = NetworkPrefix.Tag(unvotedBy, opts.IPv4PrefixLength, opts.IPv6PrefixLength, opts.NetworkTagSecret); + voted = (await dbContext.ConceptAppraisals.AsNoTracking() + .Where(x => ids.Contains(x.SubjectId) && x.NetworkTag == tag) + .Select(x => x.SubjectId) + .ToListAsync(cancellationToken)).ToHashSet(); + } + + var rows = live + .Where(s => !voted.Contains(s.Id)) + .Select(s => + { + var tally = Tally(groups.Where(g => g.SubjectId == s.Id).Select(g => (g.Stance, g.IsMember, g.Count))); + var value = order switch + { + RankOrder.Net => (long)tally.Up - tally.Down, + RankOrder.Total => (long)tally.Up + tally.Down, + _ => s.RegisteredAtUtc.UtcTicks + }; + return (Value: value, s.Key, s.Title, Tally: tally); + }) + .Where(r => after is null || r.Value < after.Value || (r.Value == after.Value && string.CompareOrdinal(r.Key, after.Key) > 0)) + .OrderByDescending(r => r.Value) + .ThenBy(r => r.Key, StringComparer.Ordinal) + .Take(limit + 1) + .ToList(); + + var page = rows.Take(limit).ToList(); + var next = rows.Count > limit + ? new RankCursor(order, unvotedBy is not null, page[^1].Value, page[^1].Key).Encode() + : null; + return new RankedPage(page.Select(r => new RankedSubject(r.Key, r.Title, r.Tally)).ToList(), next); + } + public async Task GetSubjectAsync( string collection, string key, diff --git a/src/Bifrost.Web/Features/ConceptVotes/ConceptTallyExport.cs b/src/Bifrost.Web/Features/ConceptVotes/ConceptTallyExport.cs index fb1f87f..3c3cf10 100644 --- a/src/Bifrost.Web/Features/ConceptVotes/ConceptTallyExport.cs +++ b/src/Bifrost.Web/Features/ConceptVotes/ConceptTallyExport.cs @@ -65,4 +65,89 @@ public static class ConceptTallyExport response.Headers.CacheControl = "public, max-age=60"; return Results.Json(tallies); }).AllowAnonymous().RequireCors(CorsPolicy); + + public const int DefaultPageSize = 50; + public const int MaxPageSize = 100; + + // The ranked, paged read beside the map: GET /public/concepts/{collection}/ranked + // ?sort=net|total|newest (default net) &limit=1..100 (default 50) &cursor= &unvoted=network + // returns { items: [{ key, title, up, down, members: { up, down } }], next_cursor }. The query is a closed + // shape: an unknown or repeated key, an out-of-range value or a cursor that is not exactly one this route issued + // for the same sort and filter is a 400. unvoted=network leaves out the concepts the caller's own network tag + // has already voted on; the tag is derived from the connection and compared inside the query, never accepted + // from the request and never returned. That answer depends on the caller, so it is never cached. + public static IEndpointConventionBuilder MapConceptRankedExport(this IEndpointRouteBuilder app) => + app.MapGet(RoutePrefix + "/{collection}/ranked", async ( + string collection, + HttpContext http, + ConceptAppraisalService appraisals, + CancellationToken cancellationToken) => + { + if (!ConceptKey.IsValid(collection)) + { + return Results.NotFound(); + } + + if (!TryReadRankedQuery(http.Request.Query, out var order, out var limit, out var cursor, out var unvoted)) + { + return JsonText(new { error = "request_invalid" }, StatusCodes.Status400BadRequest); + } + + var page = await appraisals.GetRankedAsync( + collection, order, limit, cursor, unvoted ? http.Connection.RemoteIpAddress : null, cancellationToken); + if (page is null) + { + return Results.NotFound(); + } + + http.Response.Headers.CacheControl = unvoted ? "private, no-store" : "public, max-age=60"; + return JsonText(new + { + items = page.Items.Select(i => new + { + key = i.Key, + title = i.Title, + up = i.Tally.Up, + down = i.Tally.Down, + members = new { up = i.Tally.Members.Up, down = i.Tally.Members.Down } + }), + next_cursor = page.NextCursor + }, StatusCodes.Status200OK); + }).AllowAnonymous().RequireCors(CorsPolicy); + + private static IResult JsonText(object body, int status) => + Results.Text(System.Text.Json.JsonSerializer.Serialize(body), "application/json", statusCode: status); + + internal static bool TryReadRankedQuery( + IQueryCollection query, out RankOrder order, out int limit, out RankCursor? cursor, out bool unvoted) + { + order = RankOrder.Net; + limit = DefaultPageSize; + cursor = null; + unvoted = false; + foreach (var (key, values) in query) + { + if (values.Count != 1 || values[0] is not { } value) + { + return false; + } + + switch (key) + { + case "sort" when value == "net": order = RankOrder.Net; break; + case "sort" when value == "total": order = RankOrder.Total; break; + case "sort" when value == "newest": order = RankOrder.Newest; break; + case "limit" when int.TryParse(value, System.Globalization.NumberStyles.None, + System.Globalization.CultureInfo.InvariantCulture, out var n) && + n is >= 1 and <= MaxPageSize && value == n.ToString(System.Globalization.CultureInfo.InvariantCulture): + limit = n; + break; + case "cursor" when RankCursor.TryDecode(value, out var decoded): cursor = decoded; break; + case "unvoted" when value == "network": unvoted = true; break; + default: return false; + } + } + + return cursor is null || (cursor.Order == order && cursor.Unvoted == unvoted); + } } diff --git a/src/Bifrost.Web/Features/ConceptVotes/RankCursor.cs b/src/Bifrost.Web/Features/ConceptVotes/RankCursor.cs new file mode 100644 index 0000000..461f7e4 --- /dev/null +++ b/src/Bifrost.Web/Features/ConceptVotes/RankCursor.cs @@ -0,0 +1,62 @@ +using System.Globalization; +using System.Text; +using System.Text.RegularExpressions; +using Microsoft.AspNetCore.WebUtilities; + +namespace Bifrost.Web.Features.ConceptVotes; + +// The opaque cursor of the ranked read: the last row's sort value and key, plus the order and filter it was issued +// for, so a cursor cannot be replayed under another order or filter. Text "1|order|u|value|key", base64url without +// padding. Not signed: it names a position in public data and grants nothing. Decoding is strict and canonical (a +// cursor that does not re-encode to the same text is refused), and every field is re-validated, so no cursor text +// reaches a query unparsed. +public sealed record RankCursor(RankOrder Order, bool Unvoted, long Value, string Key) +{ + public const int MaxLength = 200; + + private static readonly Regex Alphabet = new(@"^[A-Za-z0-9_-]+\z", RegexOptions.CultureInvariant); + + public string Encode() => + WebEncoders.Base64UrlEncode(Encoding.UTF8.GetBytes( + string.Create(CultureInfo.InvariantCulture, $"1|{(int)Order}|{(Unvoted ? 1 : 0)}|{Value}|{Key}"))); + + public static bool TryDecode(string? text, out RankCursor cursor) + { + cursor = null!; + if (string.IsNullOrEmpty(text) || text.Length > MaxLength || !Alphabet.IsMatch(text)) + { + return false; + } + + string plain; + try + { + var bytes = WebEncoders.Base64UrlDecode(text); + plain = new UTF8Encoding(false, true).GetString(bytes); + } + catch (Exception ex) when (ex is FormatException or ArgumentException) + { + return false; + } + + var parts = plain.Split('|'); + if (parts.Length != 5 || parts[0] != "1" || + !int.TryParse(parts[1], NumberStyles.None, CultureInfo.InvariantCulture, out var order) || + !Enum.IsDefined((RankOrder)order) || + parts[2] is not ("0" or "1") || + !long.TryParse(parts[3], NumberStyles.AllowLeadingSign, CultureInfo.InvariantCulture, out var value) || + !ConceptKey.IsValid(parts[4])) + { + return false; + } + + var candidate = new RankCursor((RankOrder)order, parts[2] == "1", value, parts[4]); + if (candidate.Encode() != text) + { + return false; + } + + cursor = candidate; + return true; + } +} diff --git a/src/Bifrost.Web/Program.cs b/src/Bifrost.Web/Program.cs index bf6103a..923ed4e 100644 --- a/src/Bifrost.Web/Program.cs +++ b/src/Bifrost.Web/Program.cs @@ -293,6 +293,7 @@ app.MapGet("/readyz", async (ReadinessService readinessService, CancellationToke }).AllowAnonymous(); app.MapConceptTallyExport(); +app.MapConceptRankedExport(); if (oidcOptions is not null) { diff --git a/tests/Bifrost.Web.Tests/ConceptRankedExportTests.cs b/tests/Bifrost.Web.Tests/ConceptRankedExportTests.cs new file mode 100644 index 0000000..0b31fa5 --- /dev/null +++ b/tests/Bifrost.Web.Tests/ConceptRankedExportTests.cs @@ -0,0 +1,519 @@ +using System.Net; +using System.Text; +using System.Text.Json; +using Bifrost.Web.Data; +using Bifrost.Web.Domain; +using Bifrost.Web.Features.ConceptVotes; +using Bifrost.Web.Features.Shared; +using Bifrost.Web.Tests.Support; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Hosting; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; + +namespace Bifrost.Web.Tests; + +public sealed class ConceptRankedExportTests : IClassFixture +{ + private const string AllowedOrigin = "https://aetheria.gamecult.org"; + private static readonly DateTimeOffset Epoch = new(2026, 11, 1, 0, 0, 0, TimeSpan.Zero); + + private readonly RankedFactory _factory; + + public ConceptRankedExportTests(RankedFactory factory) + { + _factory = factory; + } + + [Fact] + public async Task Net_ranks_up_minus_down_and_ties_break_by_key() + { + var c = await Seed(); + + var keys = Keys(await GetOk(c, "?sort=net")); + + Assert.Equal(["bravo", "alpha", "charlie", "delta", "echo"], keys); + } + + [Fact] + public async Task Default_order_is_net_and_the_default_page_is_everything_under_the_cap() + { + var c = await Seed(); + + var body = await GetOk(c, ""); + + Assert.Equal(Keys(await GetOk(c, "?sort=net")), Keys(body)); + Assert.Equal(JsonValueKind.Null, body.GetProperty("next_cursor").ValueKind); + } + + [Fact] + public async Task Total_ranks_up_plus_down() + { + var c = await Seed(); + + Assert.Equal(["alpha", "bravo", "charlie", "delta", "echo"], Keys(await GetOk(c, "?sort=total"))); + } + + [Fact] + public async Task Newest_ranks_by_registration_time() + { + var c = await Seed(); + + Assert.Equal(["echo", "delta", "charlie", "bravo", "alpha"], Keys(await GetOk(c, "?sort=newest"))); + } + + [Fact] + public async Task Items_carry_counts_and_title_only() + { + var c = await Seed(); + + var first = (await GetOk(c, "?limit=1")).GetProperty("items")[0]; + + Assert.Equal(["down", "key", "members", "title", "up"], first.EnumerateObject().Select(p => p.Name).Order().ToArray()); + Assert.Equal(["down", "up"], first.GetProperty("members").EnumerateObject().Select(p => p.Name).Order().ToArray()); + } + + [Theory] + [InlineData("net")] + [InlineData("total")] + [InlineData("newest")] + public async Task Paging_walks_every_subject_once_in_the_same_order_as_one_big_page(string sort) + { + var c = await Seed(); + var whole = Keys(await GetOk(c, $"?sort={sort}")); + + var walked = new List(); + string? cursor = null; + var pages = 0; + do + { + var body = await GetOk(c, $"?sort={sort}&limit=2" + (cursor is null ? "" : "&cursor=" + cursor)); + walked.AddRange(Keys(body)); + cursor = body.GetProperty("next_cursor").ValueKind == JsonValueKind.Null ? null : body.GetProperty("next_cursor").GetString(); + pages++; + } + while (cursor is not null && pages < 10); + + Assert.Equal(whole, walked); + Assert.Equal(3, pages); + } + + [Fact] + public async Task A_vote_between_pages_never_repeats_the_cursors_own_row() + { + var c = await Seed(); + var first = await GetOk(c, "?limit=2"); + var seen = Keys(first); + + await Cast(c, "echo", "mover-1", ConceptStance.Up); + await Cast(c, "echo", "mover-2", ConceptStance.Up); + await Cast(c, "echo", "mover-3", ConceptStance.Up); + await Cast(c, "echo", "mover-4", ConceptStance.Up); + var second = Keys(await GetOk(c, "?limit=5&cursor=" + first.GetProperty("next_cursor").GetString())); + + Assert.Empty(seen.Intersect(second)); + } + + [Theory] + [InlineData("?limit=0")] + [InlineData("?limit=101")] + [InlineData("?limit=-1")] + [InlineData("?limit=+5")] + [InlineData("?limit=05")] + [InlineData("?limit=5.0")] + [InlineData("?limit=%205")] + [InlineData("?limit=")] + [InlineData("?limit=abc")] + [InlineData("?limit=99999999999999999999")] + [InlineData("?sort=")] + [InlineData("?sort=oldest")] + [InlineData("?sort=Net")] + [InlineData("?sort=net&sort=total")] + [InlineData("?limit=1&limit=2")] + [InlineData("?unvoted=")] + [InlineData("?unvoted=all")] + [InlineData("?unvoted=network&unvoted=network")] + [InlineData("?Sort=net")] + [InlineData("?tag=abc")] + [InlineData("?networkTag=abc")] + [InlineData("?page=2")] + public async Task A_query_outside_the_closed_shape_is_a_400(string query) + { + var c = await Seed(); + + using var response = await Get(c, query); + + Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode); + Assert.Equal("request_invalid", JsonDocument.Parse(await response.Content.ReadAsStringAsync()).RootElement.GetProperty("error").GetString()); + } + + [Fact] + public async Task The_page_cap_is_one_hundred_and_one_hundred_is_allowed() + { + var c = await Seed(); + + using var ok = await Get(c, "?limit=100"); + using var over = await Get(c, "?limit=101"); + + Assert.Equal(HttpStatusCode.OK, ok.StatusCode); + Assert.Equal(HttpStatusCode.BadRequest, over.StatusCode); + } + + public static IEnumerable HostileCursors() + { + static string B64(string text) => Convert.ToBase64String(Encoding.UTF8.GetBytes(text)).TrimEnd('=').Replace('+', '-').Replace('/', '_'); + yield return ["x"]; + yield return ["%00"]; + yield return ["not base64!"]; + yield return ["AAAA"]; + yield return [new string('A', 201)]; + yield return [new string('A', 100_000)]; + yield return [B64("")]; + yield return [B64("1|0|0|5")]; + yield return [B64("1|0|0|5|alpha|extra")]; + yield return [B64("2|0|0|5|alpha")]; + yield return [B64("1|3|0|5|alpha")]; + yield return [B64("1|-1|0|5|alpha")]; + yield return [B64("1|0|2|5|alpha")]; + yield return [B64("1|0|0|abc|alpha")]; + yield return [B64("1|0|0|+5|alpha")]; + yield return [B64("1|0|0|-0|alpha")]; + yield return [B64("1|0|0|05|alpha")]; + yield return [B64("1|0|0|99999999999999999999|alpha")]; + yield return [B64("1|0|0|5|Alpha")]; + yield return [B64("1|0|0|5|al pha")]; + yield return [B64("1|0|0|5|alpha\n")]; + yield return [B64("1|0|0|5|'; drop table")]; + yield return [B64("1|0|0|5|../x")]; + yield return [B64("1|0|0|5|" + new string('a', 81))]; + yield return [B64("1|0|0|5|")]; + yield return [Convert.ToBase64String(Encoding.UTF8.GetBytes("1|0|0|5|alpha"))]; + yield return [B64("1|0|0|5|alpha") + "="]; + yield return [Convert.ToBase64String([0xFF, 0xFE, 0xFD, 0xFC]).Replace('+', '-').Replace('/', '_').TrimEnd('=')]; + } + + [Theory] + [MemberData(nameof(HostileCursors))] + public async Task A_hostile_cursor_is_a_400_and_never_a_query(string cursor) + { + var c = await Seed(); + + using var response = await Get(c, "?cursor=" + Uri.EscapeDataString(cursor)); + + Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode); + } + + [Fact] + public async Task A_cursor_is_bound_to_the_sort_and_the_filter_it_was_issued_for() + { + var c = await Seed(); + var net = (await GetOk(c, "?limit=1")).GetProperty("next_cursor").GetString()!; + var unvoted = (await GetOk(c, "?limit=1&unvoted=network", client: "198.51.100.9")).GetProperty("next_cursor").GetString()!; + + using var otherSort = await Get(c, "?sort=total&cursor=" + net); + using var dropsFilter = await Get(c, "?cursor=" + unvoted); + using var addsFilter = await Get(c, "?unvoted=network&cursor=" + net, client: "198.51.100.9"); + using var same = await Get(c, "?limit=1&cursor=" + net); + + Assert.Equal(HttpStatusCode.BadRequest, otherSort.StatusCode); + Assert.Equal(HttpStatusCode.BadRequest, dropsFilter.StatusCode); + Assert.Equal(HttpStatusCode.BadRequest, addsFilter.StatusCode); + Assert.Equal(HttpStatusCode.OK, same.StatusCode); + } + + [Fact] + public async Task A_well_formed_cursor_past_every_row_is_an_empty_last_page() + { + var c = await Seed(); + var past = new RankCursor(RankOrder.Net, false, long.MinValue, "a").Encode(); + + var body = await GetOk(c, "?cursor=" + past); + + Assert.Empty(Keys(body)); + Assert.Equal(JsonValueKind.Null, body.GetProperty("next_cursor").ValueKind); + } + + [Theory] + [InlineData("Alpha")] + [InlineData("a_b")] + [InlineData("..")] + [InlineData("a%2Fb")] + [InlineData("a%0A")] + [InlineData("%27%3B--")] + [InlineData("nope")] + public async Task A_hostile_or_unknown_collection_is_a_404(string collection) + { + using var response = await Get(collection, ""); + + Assert.Equal(HttpStatusCode.NotFound, response.StatusCode); + } + + [Fact] + public async Task A_collection_over_the_key_grammar_is_a_404() + { + using var response = await Get(new string('a', 81), ""); + + Assert.Equal(HttpStatusCode.NotFound, response.StatusCode); + } + + [Fact] + public async Task Retired_subjects_are_left_out() + { + var c = await Seed(); + using (var scope = _factory.Services.CreateScope()) + { + var db = scope.ServiceProvider.GetRequiredService(); + (await db.ConceptSubjects.SingleAsync(x => x.Collection == c && x.Key == "bravo")).RetiredAtUtc = Epoch; + await db.SaveChangesAsync(); + } + + Assert.DoesNotContain("bravo", Keys(await GetOk(c, ""))); + } + + [Fact] + public async Task Unvoted_leaves_out_what_the_callers_network_voted_and_not_what_others_did() + { + var c = await Seed(); + + var fromVoter = Keys(await GetOk(c, "?unvoted=network", client: Voter)); + var fromStranger = Keys(await GetOk(c, "?unvoted=network", client: "198.51.100.77")); + + Assert.Equal(["charlie", "delta", "echo"], fromVoter.Order().ToArray()); + Assert.Equal(["alpha", "bravo", "charlie", "delta", "echo"], fromStranger.Order().ToArray()); + } + + [Fact] + public async Task Unvoted_pages_and_ranks_the_same_way() + { + var c = await Seed(); + + var walked = new List(); + string? cursor = null; + do + { + var body = await GetOk(c, "?unvoted=network&sort=newest&limit=1" + (cursor is null ? "" : "&cursor=" + cursor), client: "198.51.100.77"); + walked.AddRange(Keys(body)); + cursor = body.GetProperty("next_cursor").ValueKind == JsonValueKind.Null ? null : body.GetProperty("next_cursor").GetString(); + } + while (cursor is not null && walked.Count < 10); + + Assert.Equal(["echo", "delta", "charlie", "bravo", "alpha"], walked); + } + + [Fact] + public async Task Unvoted_without_a_client_address_filters_nothing_and_matches_no_untagged_row() + { + var c = await Seed(); + await CastUntagged(c, "echo", "ghost-device"); + + var keys = Keys(await GetOk(c, "?unvoted=network", client: null)); + + Assert.Equal(5, keys.Length); + } + + [Fact] + public async Task Unvoted_and_plain_responses_expose_no_tag_or_voter_identity() + { + var c = await Seed(); + string[] tags; + using (var scope = _factory.Services.CreateScope()) + { + tags = await scope.ServiceProvider.GetRequiredService() + .ConceptAppraisals.Where(x => x.NetworkTag != "").Select(x => x.NetworkTag).Distinct().ToArrayAsync(); + } + + Assert.NotEmpty(tags); + foreach (var query in new[] { "", "?unvoted=network", "?limit=2&unvoted=network" }) + { + using var response = await Get(c, query, client: Voter); + var text = await response.Content.ReadAsStringAsync(); + foreach (var forbidden in tags.Concat(["voter-", "device:", "account:", "networktag", "voterkey", Voter])) + { + Assert.DoesNotContain(forbidden, text, StringComparison.OrdinalIgnoreCase); + } + } + + // The cursor is position only: it carries no tag either. + var cursor = (await GetOk(c, "?limit=1&unvoted=network", client: Voter)).GetProperty("next_cursor").GetString()!; + var decoded = Encoding.UTF8.GetString(Microsoft.AspNetCore.WebUtilities.WebEncoders.Base64UrlDecode(cursor)); + foreach (var tag in tags) + { + Assert.DoesNotContain(tag, decoded); + } + } + + [Fact] + public async Task Nothing_in_the_request_can_name_a_tag_or_a_client() + { + var c = await Seed(); + using var client = _factory.CreateClient(); + using var request = new HttpRequestMessage(HttpMethod.Get, $"/public/concepts/{c}/ranked?unvoted=network"); + request.Headers.Add("X-Test-Anonymous", "true"); + request.Headers.Add("X-Test-Client", "198.51.100.77"); + request.Headers.Add("X-Network-Tag", "anything"); + request.Headers.Add("X-Forwarded-For", Voter); + + using var response = await client.SendAsync(request); + + // The test connection address wins; the forwarded header is the proxy's to set, not the request's. + Assert.Equal(5, Keys(JsonDocument.Parse(await response.Content.ReadAsStringAsync()).RootElement).Length); + } + + [Fact] + public async Task Cache_control_is_public_for_the_plain_read_and_never_cached_for_the_unvoted_one() + { + var c = await Seed(); + + using var plain = await Get(c, ""); + using var unvoted = await Get(c, "?unvoted=network", client: Voter); + + Assert.True(plain.Headers.CacheControl!.Public); + Assert.Equal(TimeSpan.FromSeconds(60), plain.Headers.CacheControl.MaxAge); + Assert.True(unvoted.Headers.CacheControl!.Private); + Assert.True(unvoted.Headers.CacheControl.NoStore); + Assert.False(unvoted.Headers.CacheControl.Public); + } + + [Fact] + public async Task Cors_and_vary_follow_the_tally_export_policy_and_the_read_sets_no_cookie_and_writes_nothing() + { + var c = await Seed(); + int rows, audits; + using (var scope = _factory.Services.CreateScope()) + { + var db = scope.ServiceProvider.GetRequiredService(); + rows = db.ConceptAppraisals.Count(); + audits = db.AuditEvents.Count(); + } + + using var allowed = await Get(c, "", origin: AllowedOrigin); + using var other = await Get(c, "", origin: "https://evil.example"); + using var bad = await Get(c, "?limit=0", origin: AllowedOrigin); + + Assert.Equal([AllowedOrigin], allowed.Headers.GetValues("Access-Control-Allow-Origin").ToArray()); + Assert.False(allowed.Headers.Contains("Access-Control-Allow-Credentials")); + Assert.False(other.Headers.Contains("Access-Control-Allow-Origin")); + foreach (var response in new[] { allowed, other, bad }) + { + Assert.Contains("Origin", response.Headers.Vary.ToArray()); + Assert.False(response.Headers.Contains("Set-Cookie")); + } + + using var after = _factory.Services.CreateScope(); + var dbAfter = after.ServiceProvider.GetRequiredService(); + Assert.Equal(rows, dbAfter.ConceptAppraisals.Count()); + Assert.Equal(audits, dbAfter.AuditEvents.Count()); + } + + // The network that cast the votes in Seed. + private const string Voter = "203.0.113.7"; + + // alpha 3 up 2 down (net 1, total 5), bravo 2 up (net 2, total 2), charlie 1 up 1 down (net 0, total 2), + // delta and echo unvoted (net 0, total 0). Registered alpha first, echo last. Voter's network cast alpha and + // bravo; charlie's votes came from a second network. + private async Task Seed() + { + var c = "c" + Guid.NewGuid().ToString("N"); + using (var scope = _factory.Services.CreateScope()) + { + var db = scope.ServiceProvider.GetRequiredService(); + var i = 0; + foreach (var key in new[] { "alpha", "bravo", "charlie", "delta", "echo" }) + { + db.ConceptSubjects.Add(new ConceptSubject { Collection = c, Key = key, Title = key.ToUpperInvariant(), RegisteredAtUtc = Epoch.AddDays(i++) }); + } + + await db.SaveChangesAsync(); + } + + await Cast(c, "alpha", "voter-1", ConceptStance.Up); + await Cast(c, "alpha", "voter-2", ConceptStance.Up); + await Cast(c, "alpha", "voter-3", ConceptStance.Up); + await Cast(c, "alpha", "voter-4", ConceptStance.Down); + await Cast(c, "alpha", "voter-5", ConceptStance.Down); + await Cast(c, "bravo", "voter-1", ConceptStance.Up); + await Cast(c, "bravo", "voter-2", ConceptStance.Up); + await Cast(c, "charlie", "other-1", ConceptStance.Up, "192.0.2.50"); + await Cast(c, "charlie", "other-2", ConceptStance.Down, "192.0.2.50"); + return c; + } + + private async Task Cast(string collection, string key, string device, ConceptStance stance, string client = Voter) + { + using var scope = _factory.Services.CreateScope(); + var voter = VoterIdentity.For(CurrentBifrostActor.Anonymous, device)!; + var result = await scope.ServiceProvider.GetRequiredService() + .CastAsync(collection, key, voter, stance, IPAddress.Parse(client), default); + Assert.Equal(CastResult.Recorded, result); + } + + private async Task CastUntagged(string collection, string key, string device) + { + using var scope = _factory.Services.CreateScope(); + var voter = VoterIdentity.For(CurrentBifrostActor.Anonymous, device)!; + await scope.ServiceProvider.GetRequiredService() + .CastAsync(collection, key, voter, ConceptStance.Up, null, default); + } + + private async Task Get(string collection, string query, string? client = null, string? origin = null) + { + using var http = _factory.CreateClient(); + using var request = new HttpRequestMessage(HttpMethod.Get, $"/public/concepts/{collection}/ranked{query}"); + request.Headers.Add("X-Test-Anonymous", "true"); + if (client is not null) + { + request.Headers.Add("X-Test-Client", client); + } + + if (origin is not null) + { + request.Headers.TryAddWithoutValidation("Origin", origin); + } + + return await http.SendAsync(request); + } + + private async Task GetOk(string collection, string query, string? client = null) + { + using var response = await Get(collection, query, client); + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + return JsonDocument.Parse(await response.Content.ReadAsStringAsync()).RootElement.Clone(); + } + + private static string[] Keys(JsonElement body) => + body.GetProperty("items").EnumerateArray().Select(i => i.GetProperty("key").GetString()!).ToArray(); + + // The test host has no connection address; a header stands in for the one the proxy path would have resolved. + private sealed class ClientAddressFilter : IStartupFilter + { + public Action Configure(Action next) => app => + { + app.Use((context, nextMiddleware) => + { + if (context.Request.Headers.TryGetValue("X-Test-Client", out var address)) + { + context.Connection.RemoteIpAddress = IPAddress.Parse(address.ToString()); + } + + return nextMiddleware(context); + }); + next(app); + }; + } + + public sealed class RankedFactory : TestWebApplicationFactory + { + protected override void ConfigureWebHost(IWebHostBuilder builder) + { + base.ConfigureWebHost(builder); + builder.ConfigureServices(services => services.AddTransient()); + builder.ConfigureAppConfiguration((_, configuration) => + configuration.AddInMemoryCollection(new Dictionary + { + ["ConceptVotes:AllowedOrigins:0"] = AllowedOrigin, + ["ConceptVotes:NetworkTagSecret"] = "ranked-test-tag-secret-0123456789-abcdef" + })); + } + } +} -- 2.47.3