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
159 changes: 159 additions & 0 deletions dotnet/EcencyApi.Tests/CurationDeskPayloadTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,9 @@ public class CurationDeskPayloadTests
CurationDeskWrites.MarkClear, CurationDeskWrites.Marks, CurationDeskWrites.Cursor,
CurationDeskWrites.RecommendMeta, CurationDeskWrites.RecommendationDismiss, CurationDeskWrites.Ingest,
CurationDeskWrites.RosterList, CurationDeskWrites.RosterSet, CurationDeskWrites.RosterRetire,
CurationDeskWrites.ApplicationApply, CurationDeskWrites.ApplicationMine,
CurationDeskWrites.ApplicationWithdraw, CurationDeskWrites.ApplicationList,
CurationDeskWrites.ApplicationDecide, CurationDeskWrites.ApplicationWindow,
};

private const string IngestBody =
Expand Down Expand Up @@ -59,6 +62,12 @@ private static string ValidBodyFor(CurationDeskWrites.Route route)
return "{" + forged + "\"curator\":\"bob\",\"role\":\"curator\"}";
if (ReferenceEquals(route, CurationDeskWrites.RosterRetire))
return "{" + forged + "\"curator\":\"bob\"}";
if (ReferenceEquals(route, CurationDeskWrites.ApplicationApply))
return "{" + forged + "\"motivation\":\"why\",\"availability\":\"evenings\",\"pick\":\"a post\"}";
if (ReferenceEquals(route, CurationDeskWrites.ApplicationDecide))
return "{" + forged + "\"applicant\":\"bob\",\"state\":\"declined\"}";
if (ReferenceEquals(route, CurationDeskWrites.ApplicationWindow))
return "{" + forged + "\"open\":true}";
return "{" + forged + "\"limit\":5}";
}

Expand Down Expand Up @@ -631,6 +640,156 @@ public void ARetireCarriesNothingButTheCurator()
Assert.Equal("curator required", Rejected(CurationDeskWrites.RosterRetire, "{\"curator\":\"..\"}"));
}

// ---- guest curator applications ------------------------------------------

[Fact]
public void AnApplicationForwardsTheThreeAnswersAndNothingElse()
{
var payload = Ok(CurationDeskWrites.ApplicationApply,
"{\"motivation\":\"why\",\"availability\":\"evenings\",\"pick\":\"a post\","
+ "\"code\":\"as:alice\",\"username\":\"boss\"}");
Assert.Equal(new[] { "username", "motivation", "availability", "pick" },
payload.Select(kv => kv.Key).ToArray());
// The caller is the validated account, never the one the body asked for.
Assert.Equal("alice", payload["username"]!.GetValue<string>());
}

[Theory]
[InlineData("discord")]
[InlineData("state")]
[InlineData("role")]
public void AnUnknownApplicationFieldIsRefusedRatherThanDropped(string field)
{
// The desk answers 400 for a field it does not know, so dropping one here would
// turn that refusal into a silent half-application.
Assert.Equal($"unknown field: {field}", Rejected(CurationDeskWrites.ApplicationApply,
"{\"motivation\":\"why\",\"availability\":\"evenings\",\"pick\":\"a post\","
+ $"\"{field}\":\"x\"}}"));
}

[Fact]
public void APresentButNullQueueLimitIsRefusedRatherThanForwarded()
{
Assert.Equal("limit must be a whole number from 1 to 200",
Rejected(CurationDeskWrites.ApplicationList, "{\"limit\":null}"));
// absent is still absent: that is how the backend's own default answers
Assert.False(Ok(CurationDeskWrites.ApplicationList, "{}").ContainsKey("limit"));
}

[Theory]
[InlineData("{\"availability\":\"evenings\",\"pick\":\"a post\"}", "motivation required")]
[InlineData("{\"motivation\":\" \",\"availability\":\"evenings\",\"pick\":\"a post\"}", "motivation required")]
[InlineData("{\"motivation\":\"why\",\"availability\":null,\"pick\":\"a post\"}", "availability required")]
[InlineData("{\"motivation\":\"why\",\"availability\":5,\"pick\":\"a post\"}", "availability required")]
[InlineData("{\"motivation\":\"why\",\"availability\":\"evenings\"}", "pick required")]
public void AnIncompleteApplicationIsRefused(string json, string expected)
{
Assert.Equal(expected, Rejected(CurationDeskWrites.ApplicationApply, json));
}

[Fact]
public void AnAnswerIsMeasuredTheWayTheColumnMeasuresIt()
{
var emoji = string.Concat(Enumerable.Repeat("\U0001F600", 200));
Assert.Equal(400, emoji.Length);
Assert.True(Ok(CurationDeskWrites.ApplicationApply,
"{\"motivation\":\"why\",\"availability\":\"" + emoji + "\",\"pick\":\"a post\"}")
.ContainsKey("availability"));
Assert.Equal("availability is at most 200 characters", Rejected(CurationDeskWrites.ApplicationApply,
"{\"motivation\":\"why\",\"availability\":\"" + emoji + "\U0001F600\",\"pick\":\"a post\"}"));
}

[Fact]
public void TheApplicantsOwnRoutesCarryNothingTheCallerSent()
{
foreach (var route in new[] { CurationDeskWrites.ApplicationMine, CurationDeskWrites.ApplicationWithdraw })
{
var payload = Ok(route, "{\"username\":\"boss\",\"id\":7,\"state\":\"accepted\"}");
Assert.Equal(new[] { "username" }, payload.Select(kv => kv.Key).ToArray());
Assert.Equal("alice", payload["username"]!.GetValue<string>());
}
}

[Fact]
public void ADecisionNamesTheApplicantInItsOwnField()
{
var payload = Ok(CurationDeskWrites.ApplicationDecide,
"{\"applicant\":\"bob\",\"state\":\"accepted\",\"role\":\"trial\",\"note\":\"guest curator\"}");
Assert.Equal(new[] { "username", "applicant", "state", "role", "note" },
payload.Select(kv => kv.Key).ToArray());
Assert.Equal("alice", payload["username"]!.GetValue<string>());
Assert.Equal("bob", payload["applicant"]!.GetValue<string>());
}

[Theory]
[InlineData("{\"state\":\"accepted\"}", "applicant required")]
[InlineData("{\"applicant\":\"Bob\",\"state\":\"accepted\"}", "applicant required")]
[InlineData("{\"applicant\":\"bob\\n\",\"state\":\"accepted\"}", "applicant required")]
[InlineData("{\"applicant\":\"bob\"}", "invalid state")]
[InlineData("{\"applicant\":\"bob\",\"state\":\"open\"}", "invalid state")]
[InlineData("{\"applicant\":\"bob\",\"state\":\"withdrawn\"}", "invalid state")]
[InlineData("{\"applicant\":\"bob\",\"state\":\"accepted\",\"role\":\"admin\"}", "invalid role")]
[InlineData("{\"applicant\":\"bob\",\"state\":\"accepted\",\"role\":null}", "invalid role")]
public void AMalformedDecisionIsRefusedRatherThanTrimmed(string json, string expected)
{
Assert.Equal(expected, Rejected(CurationDeskWrites.ApplicationDecide, json));
}

[Fact]
public void ANeverGrantedRoleCannotArriveThroughAnAcceptance()
{
// The seat an acceptance grants is a trial by default and a curator or mod at most.
// Admin runs the desk, so it is not something a form hands out.
Assert.DoesNotContain("admin", CurationDeskWrites.ApplicationRoles);
}

[Theory]
[InlineData("{\"state\":\"nonsense\"}", "invalid state")]
[InlineData("{\"limit\":0}", "limit must be a whole number from 1 to 200")]
[InlineData("{\"limit\":201}", "limit must be a whole number from 1 to 200")]
[InlineData("{\"limit\":\"50\"}", "limit must be a whole number from 1 to 200")]
[InlineData("{\"limit\":1.5}", "limit must be a whole number from 1 to 200")]
public void AMalformedQueueRequestIsRefused(string json, string expected)
{
Assert.Equal(expected, Rejected(CurationDeskWrites.ApplicationList, json));
}

[Fact]
public void TheQueueTakesAStateAndALimitAndNothingElse()
{
var payload = Ok(CurationDeskWrites.ApplicationList,
"{\"state\":\"open\",\"limit\":10,\"username\":\"boss\",\"cursor\":\"x\"}");
Assert.Equal(new[] { "username", "state", "limit" }, payload.Select(kv => kv.Key).ToArray());
// Neither is required: the backend's own defaults answer the common case.
Assert.Equal(new[] { "username" },
Ok(CurationDeskWrites.ApplicationList, "{}").Select(kv => kv.Key).ToArray());
}

[Theory]
[InlineData("{}", "open must be true or false")]
[InlineData("{\"message\":\"back soon\"}", "open must be true or false")]
[InlineData("{\"open\":\"false\"}", "open must be true or false")]
[InlineData("{\"open\":0}", "open must be true or false")]
[InlineData("{\"open\":null}", "open must be true or false")]
public void AWindowWithoutABooleanIsRefused(string json, string expected)
{
Assert.Equal(expected, Rejected(CurationDeskWrites.ApplicationWindow, json));
}

[Fact]
public void AClosedWindowMessageIsCappedAtTheColumn()
{
var message = new string('x', 201);
Assert.Equal("invalid message", Rejected(CurationDeskWrites.ApplicationWindow,
"{\"open\":false,\"message\":\"" + message + "\"}"));
var payload = Ok(CurationDeskWrites.ApplicationWindow,
"{\"open\":false,\"message\":\"" + message[..200] + "\"}");
Assert.Equal(new[] { "username", "open", "message" }, payload.Select(kv => kv.Key).ToArray());
// A present null clears the message, and the fence lets that through.
Assert.True(Ok(CurationDeskWrites.ApplicationWindow, "{\"open\":true,\"message\":null}")
.ContainsKey("message"));
}

[Fact]
public void TheRosterListCarriesNothingTheCallerSent()
{
Expand Down
9 changes: 9 additions & 0 deletions dotnet/EcencyApi.Tests/CurationDeskTestSupport.cs
Original file line number Diff line number Diff line change
Expand Up @@ -262,5 +262,14 @@ public static TestClock UseTestClock()
yield return ("roster-set", PrivateApi.CurationDeskRosterSet,
"{" + code + ",\"curator\":\"bob\",\"role\":\"curator\"}");
yield return ("roster-retire", PrivateApi.CurationDeskRosterRetire, "{" + code + ",\"curator\":\"bob\"}");
yield return ("application-apply", PrivateApi.CurationDeskApplicationApply,
"{" + code + ",\"motivation\":\"why\",\"availability\":\"evenings\",\"pick\":\"a post\"}");
yield return ("application-mine", PrivateApi.CurationDeskApplicationMine, "{" + code + "}");
yield return ("application-withdraw", PrivateApi.CurationDeskApplicationWithdraw, "{" + code + "}");
yield return ("application-list", PrivateApi.CurationDeskApplicationList, "{" + code + ",\"state\":\"open\"}");
yield return ("application-decide", PrivateApi.CurationDeskApplicationDecide,
"{" + code + ",\"applicant\":\"bob\",\"state\":\"accepted\",\"role\":\"trial\"}");
yield return ("application-window", PrivateApi.CurationDeskApplicationWindow,
"{" + code + ",\"open\":false,\"message\":\"back soon\"}");
}
}
159 changes: 159 additions & 0 deletions dotnet/EcencyApi/Handlers/PrivateApi.CurationDesk.cs
Original file line number Diff line number Diff line change
Expand Up @@ -454,6 +454,33 @@ public static Task CurationDeskRecommendMeta(HttpContext ctx) =>
public static Task CurationDeskIngest(HttpContext ctx) =>
ServeDeskWrite(ctx, CurationDeskWrites.Ingest);

// POST /private-api/curation-desk/application-apply
// Guest curator applications. Apply, mine and withdraw carry no role: the
// applicant is by definition not on the roster yet, and the backend decides
// what each account may do with its own application.
public static Task CurationDeskApplicationApply(HttpContext ctx) =>
ServeDeskWrite(ctx, CurationDeskWrites.ApplicationApply);
Comment thread
qodo-free-for-open-source-projects[bot] marked this conversation as resolved.

// POST /private-api/curation-desk/application-mine
public static Task CurationDeskApplicationMine(HttpContext ctx) =>
ServeDeskWrite(ctx, CurationDeskWrites.ApplicationMine);

// POST /private-api/curation-desk/application-withdraw
public static Task CurationDeskApplicationWithdraw(HttpContext ctx) =>
ServeDeskWrite(ctx, CurationDeskWrites.ApplicationWithdraw);

// POST /private-api/curation-desk/application-list
public static Task CurationDeskApplicationList(HttpContext ctx) =>
ServeDeskWrite(ctx, CurationDeskWrites.ApplicationList);

// POST /private-api/curation-desk/application-decide
public static Task CurationDeskApplicationDecide(HttpContext ctx) =>
ServeDeskWrite(ctx, CurationDeskWrites.ApplicationDecide);

// POST /private-api/curation-desk/application-window
public static Task CurationDeskApplicationWindow(HttpContext ctx) =>
ServeDeskWrite(ctx, CurationDeskWrites.ApplicationWindow);

// POST /private-api/curation-desk/recommendation-dismiss
public static Task CurationDeskRecommendationDismiss(HttpContext ctx) =>
ServeDeskWrite(ctx, CurationDeskWrites.RecommendationDismiss);
Expand Down Expand Up @@ -773,6 +800,28 @@ public sealed record Route(string UpstreamPath, string[] Keys, bool ForwardClien
/// <summary>The backend keeps the event id in a varchar(200).</summary>
public const int MaxIngestIdLength = 200;

/// <summary>
/// Guest curator applications (spec 6.6). The three questions the apply form asks, each
/// with the length the backend caps it at. Counted in RUNES, not UTF-16 units, for the
/// reason the curator note is: the column counts characters and so does Python's len().
/// </summary>
public static readonly (string Key, int Max)[] ApplicationAnswers =
{
("motivation", 500), ("availability", 200), ("pick", 500),
};
public static readonly IReadOnlySet<string> ApplicationStates =
new HashSet<string> { "open", "shortlisted", "accepted", "declined", "withdrawn" };
/// <summary>What an admin may set. `open` is not a decision and `withdrawn` is the applicant's.</summary>
public static readonly IReadOnlySet<string> ApplicationDecisions =
new HashSet<string> { "shortlisted", "accepted", "declined" };
/// <summary>Roles an acceptance may grant. `admin` is deliberately absent: desk keys are
/// not handed out by a form, and the backend refuses it too.</summary>
public static readonly IReadOnlySet<string> ApplicationRoles =
new HashSet<string> { "trial", "curator", "mod" };
public const int MaxApplicationNoteLength = 500;
public const int MaxApplicationMessageLength = 200;
public const int MaxApplicationListLimit = 200;

/// <summary>
/// Views the roster feed takes: the public ones plus `excluded`, which is
/// the only place an excluded row is ever listed.
Expand Down Expand Up @@ -848,6 +897,34 @@ public sealed record Route(string UpstreamPath, string[] Keys, bool ForwardClien
/// </summary>
public static readonly Route Ingest = new("curation/desk/ingest", new[] { "v", "type", "id", "ts", "payload" });

/// <summary>
/// Guest curator applications (spec 6.6). Apply, mine and withdraw are open to any
/// signed-in account; list, decide and window are admin-only upstream, like the roster
/// writes above, and for the same reason they are POSTs: the fields are private.
/// </summary>
public static readonly Route ApplicationApply = new("curation/desk/applications/apply",
new[] { "motivation", "availability", "pick" });

public static readonly Route ApplicationMine = new("curation/desk/applications/mine",
Array.Empty<string>());

public static readonly Route ApplicationWithdraw = new("curation/desk/applications/withdraw",
Array.Empty<string>());

public static readonly Route ApplicationList = new("curation/desk/applications/list",
new[] { "state", "limit" });

/// <summary>
/// The account being decided on is `applicant`. It can never be `username`: that key
/// carries the validated caller and Build() refuses to copy a client's version of it,
/// so an admin reusing it would only ever decide on themselves.
/// </summary>
public static readonly Route ApplicationDecide = new("curation/desk/applications/decide",
new[] { "applicant", "state", "role", "note" });

public static readonly Route ApplicationWindow = new("curation/desk/applications/window",
new[] { "open", "message" });

/// <summary>
/// The upstream body: the validated username plus the route's whitelisted
/// keys copied as the client sent them. `username` and `code` are never in
Expand Down Expand Up @@ -1085,6 +1162,88 @@ private static void Truncate(JsonObject payload, string key, int max)
{
return RequireAuthorPermlink(body) ?? RequireOneOf(body, "action", DismissActions);
}
if (ReferenceEquals(route, ApplicationApply))
{
// The desk refuses an answer key it does not know rather than storing the
// rest, so dropping one here would turn its 400 into a silent half-application:
// the applicant would be told their answers were sent, minus one.
foreach (var field in body)
{
if (field.Key is "code" or "username") continue;
if (Array.FindIndex(ApplicationAnswers, answer => answer.Key == field.Key) < 0)
{
return $"unknown field: {field.Key}";
}
}
foreach (var (key, max) in ApplicationAnswers)
{
if (body.Str(key) is not { } answer || answer.AsSpan().Trim().Length == 0)
{
return $"{key} required";
}
if (answer.EnumerateRunes().Count() > max)
{
return $"{key} is at most {max} characters";
}
}
return null;
Comment thread
qodo-free-for-open-source-projects[bot] marked this conversation as resolved.
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
if (ReferenceEquals(route, ApplicationList))
{
if (body.ContainsKey("state"))
{
var stateError = RequireOneOf(body, "state", ApplicationStates);
if (stateError != null) return stateError;
}
// A PRESENT limit is checked, null included: CopyIfPresent forwards a null
// through the allowlist, so `"limit": null` would travel upstream while the
// fence claimed every limit is a whole number in range (the same hole the
// roster's `rules` had).
if (body.TryGetPropertyValue("limit", out var limit))
{
if (limit is not JsonValue take || take.GetValueKind() is not JsonValueKind.Number
|| !take.TryGetValue<int>(out var rows) || rows < 1 || rows > MaxApplicationListLimit)
{
return $"limit must be a whole number from 1 to {MaxApplicationListLimit}";
}
}
return null;
}
if (ReferenceEquals(route, ApplicationDecide))
{
if (body.Str("applicant") is not { } applicant || !HiveNames.IsAccountName(applicant))
{
return "applicant required";
}
var decision = RequireOneOf(body, "state", ApplicationDecisions);
if (decision != null) return decision;
if (body.ContainsKey("role"))
{
var roleError = RequireOneOf(body, "role", ApplicationRoles);
if (roleError != null) return roleError;
}
if (body.TryGetPropertyValue("note", out var decisionNote) && decisionNote is not null
&& (body.Str("note") is not { } noteText
|| noteText.EnumerateRunes().Count() > MaxApplicationNoteLength))
{
return "invalid note";
}
return null;
}
if (ReferenceEquals(route, ApplicationWindow))
{
if (body.Field("open")?.GetValueKind() is not (JsonValueKind.True or JsonValueKind.False))
{
return "open must be true or false";
}
if (body.TryGetPropertyValue("message", out var message) && message is not null
&& (body.Str("message") is not { } messageText
|| messageText.EnumerateRunes().Count() > MaxApplicationMessageLength))
{
return "invalid message";
}
return null;
}
if (ReferenceEquals(route, RosterSet))
{
if (body.Str("curator") is not { } curator || !HiveNames.IsAccountName(curator))
Expand Down
Loading
Loading