Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions dotnet/EcencyApi.Tests/CachePolicyTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ public static TheoryData<string, string> AllPolicies() =>
{ "desk-roster", CachePolicy.CurationDeskRoster },
{ "desk-recommendations", CachePolicy.CurationDeskRecommendations },
{ "desk-post", CachePolicy.CurationDeskPost },
{ "desk-recommender", CachePolicy.CurationDeskRecommender },
};

public static TheoryData<string, string, int> DeskPolicies() =>
Expand All @@ -34,6 +35,7 @@ public static TheoryData<string, string, int> DeskPolicies() =>
{ "desk-roster", CachePolicy.CurationDeskRoster, 600 },
{ "desk-recommendations", CachePolicy.CurationDeskRecommendations, 30 },
{ "desk-post", CachePolicy.CurationDeskPost, 15 },
{ "desk-recommender", CachePolicy.CurationDeskRecommender, 60 },
};

[Theory]
Expand Down
141 changes: 141 additions & 0 deletions dotnet/EcencyApi.Tests/CurationDeskAuthTests.cs
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
using System.Text.Json.Nodes;
using EcencyApi.Handlers;
using EcencyApi.Infrastructure;
using Microsoft.AspNetCore.Http;
using Xunit;
using static EcencyApi.Tests.CurationDeskTestSupport;

Expand Down Expand Up @@ -384,6 +385,7 @@ public void EachPolicySharedMaxAgeIsTheMemoTtl()
Assert.Equal(600, CachePolicy.SharedMaxAge(CachePolicy.CurationDeskRoster));
Assert.Equal(30, CachePolicy.SharedMaxAge(CachePolicy.CurationDeskRecommendations));
Assert.Equal(15, CachePolicy.SharedMaxAge(CachePolicy.CurationDeskPost));
Assert.Equal(60, CachePolicy.SharedMaxAge(CachePolicy.CurationDeskRecommender));
}

// ---- the byte memo -------------------------------------------------------
Expand Down Expand Up @@ -730,4 +732,143 @@ private static readonly (string Author, string Permlink)[] MalformedPostPaths =
{
("..", "p"), ("good-karma", "a/b"), ("good-karma", "p?x=1"), ("x", "p"),
};

// ---- the recommender scorecard -------------------------------------------

private static DefaultHttpContext RecommenderRequest(string username, string query = "") =>
Get("/private-api/curation-desk/recommender/" + username, query, new[] { ("username", username) });

private const string Scorecard =
"{\"username\":\"good-karma\",\"window_days\":90,\"recommended\":12,\"curated\":9,"
+ "\"dismissed\":1,\"withdrawn\":0,\"precision\":0.75,\"trusted\":true,\"computed_at\":\"t\"}";

[Fact]
public async Task AScorecardIsPipedUnderTheTokenAndCachedForAMinute()
{
var upstream = Install();
var clock = UseTestClock();
upstream.Answer = _ => Task.FromResult(JsonResponse(200, Scorecard));

var fill = RecommenderRequest("good-karma");
await PrivateApi.CurationDeskRecommender(fill);
await Start(fill);

var call = Assert.Single(upstream.Calls);
Assert.Equal("curation/desk/recommenders/good-karma", call.Endpoint);
Assert.Equal(HttpMethod.Get, call.Method);
Assert.Equal(Token, call.Header(PrivateApi.DeskTokenHeader));
Assert.Equal(200, fill.Response.StatusCode);
Assert.Equal(Scorecard, Body(fill));
Assert.StartsWith("application/json", fill.Response.ContentType);
Assert.Equal("public, max-age=0, s-maxage=60", CacheControl(fill));
Assert.Equal(CachePolicy.CurationDeskRecommender, CacheControl(fill));
Assert.Null(Age(fill));

// 45 s into the minute the policy promises: the hit is offered only the
// rest of that window; its age is not advertised a second time.
clock.Advance(TimeSpan.FromSeconds(45));
var hit = RecommenderRequest("good-karma");
await PrivateApi.CurationDeskRecommender(hit);
await Start(hit);
Assert.Equal(200, hit.Response.StatusCode);
Assert.Equal(Scorecard, Body(hit));
Assert.Equal("public, max-age=0, s-maxage=15", CacheControl(hit));
Assert.Null(Age(hit));
Assert.Single(upstream.Calls);
}

[Fact]
public async Task TheScorecardIsKeyedByTheNameAloneAndTakesNoQueryParameters()
{
var upstream = Install();
upstream.Answer = _ => Task.FromResult(JsonResponse(200, Scorecard));

await PrivateApi.CurationDeskRecommender(RecommenderRequest("good-karma"));
await PrivateApi.CurationDeskRecommender(RecommenderRequest("good-karma", "window_days=7&limit=50"));

// One question, one upstream call and one memo entry: a query string
// cannot fork the key or reach the backend.
var call = Assert.Single(upstream.Calls);
Assert.Equal("curation/desk/recommenders/good-karma", call.Endpoint);
Assert.True(CurationDeskMemo.TryGetFresh("curation/desk/recommenders/good-karma", out _, out _, out _));
Assert.Equal(1, CurationDeskMemo.Fresh.Count);

// A different name is a different entry.
await PrivateApi.CurationDeskRecommender(RecommenderRequest("user.name"));
Assert.Equal(2, upstream.Calls.Count);
Assert.Equal("curation/desk/recommenders/user.name", upstream.Calls[1].Endpoint);
}

[Fact]
public async Task AnInvalidRecommenderNameIs400BeforeAnyUpstreamCall()
{
var upstream = Install();
foreach (var username in MalformedRecommenderNames)
{
var ctx = RecommenderRequest(username);
await PrivateApi.CurationDeskRecommender(ctx);
await Start(ctx);
Assert.Equal(400, ctx.Response.StatusCode);
Assert.Equal("Invalid username", Body(ctx));
Assert.Null(CacheControl(ctx));
}
Assert.Empty(upstream.Calls);
}

[Fact]
public async Task WithoutTheTokenAMalformedRecommenderNameIs503LikeEveryOtherRoute()
{
var upstream = Install(token: null);

// The 503 is decided before the route value is read, so a dark desk does
// not single this route out by reporting on the name it was given.
foreach (var username in MalformedRecommenderNames)
{
var ctx = RecommenderRequest(username);
await PrivateApi.CurationDeskRecommender(ctx);
await Start(ctx);
Assert.Equal(503, ctx.Response.StatusCode);
Assert.Equal("curation desk not configured", Body(ctx));
Assert.Null(CacheControl(ctx));
}

// Including a request that carries no route value at all.
var bare = Get("/private-api/curation-desk/recommender/good-karma");
await PrivateApi.CurationDeskRecommender(bare);
await Start(bare);
Assert.Equal(503, bare.Response.StatusCode);
Assert.Equal("curation desk not configured", Body(bare));
Assert.Empty(upstream.Calls);
}

[Fact]
public async Task AScorecardGoesThroughTheSameFenceAsEveryOtherPublicBody()
{
var upstream = Install();
upstream.Answer = _ => Task.FromResult(JsonResponse(200,
"{\"username\":\"good-karma\",\"precision\":0.75,\"trusted\":true,\"ip_hash\":\"ab\","
+ "\"key_id\":3,\"note\":\"secret\",\"computed_at\":\"t\"}"));

var ctx = RecommenderRequest("good-karma");
await PrivateApi.CurationDeskRecommender(ctx);
await Start(ctx);
Assert.Equal(200, ctx.Response.StatusCode);
var body = Body(ctx);
Assert.Contains("\"precision\":0.75", body);
Assert.Contains("\"computed_at\":\"t\"", body);
Assert.DoesNotContain("ip_hash", body);
Assert.DoesNotContain("key_id", body);
Assert.DoesNotContain("note", body);

// The memo holds the stripped bytes, so a hit cannot leak them either.
var hit = RecommenderRequest("good-karma");
await PrivateApi.CurationDeskRecommender(hit);
Assert.Equal(body, Body(hit));
Assert.Single(upstream.Calls);
}

private static readonly string[] MalformedRecommenderNames =
{
"..", "a%2Fb", "good-karma?x=1", "Good-Karma", new string('a', 17),
};
}
69 changes: 69 additions & 0 deletions dotnet/EcencyApi.Tests/CurationDeskPayloadTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -344,6 +344,16 @@ public void RealAuthorsAndPermlinksMapToTheUpstreamPathUnchanged()
[InlineData("good-karma", "a_b")]
[InlineData("", "p")]
[InlineData("undefined-undefined", "p")]
// The right alphabet and length, but not a name: labels must be three or
// more characters, start with a letter and end with a letter or digit.
[InlineData("-ab", "p")]
[InlineData("abc-", "p")]
[InlineData("a..b", "p")]
[InlineData("ab.cdef", "p")]
[InlineData("...", "p")]
[InlineData(".abc", "p")]
[InlineData("abc.", "p")]
[InlineData("1abc", "p")]
// `$` would match before a trailing newline; these anchor with \A and \z.
[InlineData("good-karma\n", "p")]
[InlineData("good-karma", "p\n")]
Expand All @@ -360,4 +370,63 @@ public void ThePermlinkLengthBoundIsEnforced()
Assert.NotNull(PrivateApi.CurationDeskPostPath(new string('a', 16), "p"));
Assert.Null(PrivateApi.CurationDeskPostPath(new string('a', 17), "p"));
}

// ---- route 14 path -------------------------------------------------------

[Fact]
public void ARecommenderNameMapsToTheUpstreamPathUnchanged()
{
Assert.Equal("curation/desk/recommenders/good-karma",
PrivateApi.CurationDeskRecommenderPath("good-karma"));
Assert.Equal("curation/desk/recommenders/user.name",
PrivateApi.CurationDeskRecommenderPath("user.name"));
Assert.Equal("curation/desk/recommenders/a-b",
PrivateApi.CurationDeskRecommenderPath("a-b"));
Assert.Equal("curation/desk/recommenders/abc1.d-2e.fgh",
PrivateApi.CurationDeskRecommenderPath("abc1.d-2e.fgh"));
Assert.Equal("curation/desk/recommenders/" + new string('a', 16),
PrivateApi.CurationDeskRecommenderPath(new string('a', 16)));
}

[Theory]
// Dot segments would resolve upward once the string becomes a Uri.
[InlineData("..")]
[InlineData(".")]
// Route values arrive percent-decoded, so a slash is a slash; and the
// still-encoded spelling is not a name character either.
[InlineData("a/b")]
[InlineData("a%2Fb")]
// A question mark or hash would truncate the path.
[InlineData("good-karma?x=1")]
[InlineData("good-karma#f")]
// Outside the Hive name grammar, on either side of the length bound.
[InlineData("")]
[InlineData("ab")]
[InlineData("Good-Karma")]
[InlineData("good_karma")]
// The right alphabet and length, but not a name: labels must be three or
// more characters, start with a letter and end with a letter or digit.
[InlineData("-ab")]
[InlineData("abc-")]
[InlineData("a..b")]
[InlineData("ab.cdef")]
[InlineData("...")]
[InlineData(".abc")]
[InlineData("abc.")]
[InlineData("1abc")]
[InlineData("abc.-def")]
[InlineData("abc.def-")]
// `$` would match before a trailing newline; this anchors with \A and \z.
[InlineData("good-karma\n")]
public void ANameOutsideTheGrammarHasNoRecommenderPath(string username)
{
Assert.Null(PrivateApi.CurationDeskRecommenderPath(username));
}

[Fact]
public void TheRecommenderNameLengthBoundIsEnforced()
{
Assert.NotNull(PrivateApi.CurationDeskRecommenderPath(new string('a', 3)));
Assert.Null(PrivateApi.CurationDeskRecommenderPath(new string('a', 17)));
}
}
2 changes: 1 addition & 1 deletion dotnet/EcencyApi.Tests/CurationDeskPublicPayloadTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -142,6 +142,6 @@ public async Task EveryPublicRouteServesAndMemoizesTheStrippedBody()
AssertClean(JsonNode.Parse(Body(again)));
}

Assert.Equal(5, upstream.Calls.Count);
Assert.Equal(PublicReads().Count(), upstream.Calls.Count);
}
}
3 changes: 3 additions & 0 deletions dotnet/EcencyApi.Tests/CurationDeskTestSupport.cs
Original file line number Diff line number Diff line change
Expand Up @@ -239,6 +239,9 @@ public static TestClock UseTestClock()
yield return ("post", PrivateApi.CurationDeskPost,
() => Get("/private-api/curation-desk/post/good-karma/hello-world", "",
new[] { ("author", "good-karma"), ("permlink", "hello-world") }), CachePolicy.CurationDeskPost);
yield return ("recommender", PrivateApi.CurationDeskRecommender,
() => Get("/private-api/curation-desk/recommender/good-karma", "",
new[] { ("username", "good-karma") }), CachePolicy.CurationDeskRecommender);
}

/// <summary>Every signed write, as (handler, a body that passes validation).</summary>
Expand Down
62 changes: 62 additions & 0 deletions dotnet/EcencyApi.Tests/HiveNamesTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
using EcencyApi.Infrastructure;
using Xunit;

namespace EcencyApi.Tests;

/// <summary>
/// The account name grammar behind every desk path: the chain's
/// <c>is_valid_account_name</c>, label by label.
/// </summary>
public class HiveNamesTests
{
[Theory]
[InlineData("abc")]
[InlineData("a-b")]
[InlineData("a1b")]
[InlineData("good-karma")]
[InlineData("user.name")]
[InlineData("abc1.d-2e.fgh")]
[InlineData("a--b")]
[InlineData("abcdefghijklmnop")]
public void EveryLabelOfAValidNameStartsWithALetterAndEndsWithALetterOrDigit(string name)
{
Assert.True(HiveNames.IsAccountName(name));
}

[Theory]
// Length bounds.
[InlineData("")]
[InlineData("ab")]
[InlineData("abcdefghijklmnopq")]
// Alphabet.
[InlineData("Abc")]
[InlineData("a_b")]
[InlineData("abc\n")]
[InlineData("ab c")]
// Label edges.
[InlineData("-ab")]
[InlineData("1ab")]
[InlineData("abc-")]
[InlineData("abc.-de")]
[InlineData("abc.def-")]
[InlineData("abc.1de")]
// Label length.
[InlineData("ab.cdef")]
[InlineData("abcd.ef")]
[InlineData("a..b")]
[InlineData("...")]
// Empty first or last label.
[InlineData(".abc")]
[InlineData("abc.")]
[InlineData("abc..def")]
public void AnythingElseIsNotAName(string name)
{
Assert.False(HiveNames.IsAccountName(name));
}

[Fact]
public void ANullNameIsNotAName()
{
Assert.False(HiveNames.IsAccountName(null));
}
}
Loading
Loading