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
150 changes: 150 additions & 0 deletions dotnet/EcencyApi.Tests/AiTranscribeContentTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,150 @@
using System.Text;
using EcencyApi.Handlers;
using Xunit;

namespace EcencyApi.Tests;

/// <summary>
/// The transcription route is the only private-api endpoint that forwards a file, so it
/// is the only one building a multipart body by hand. Two things about that body have
/// consequences beyond a failed request:
///
/// `us` decides whose Points upstream burns. It has to be the caller resolved from the
/// signed code; taking it from the request would let anyone spend anyone else's balance.
///
/// The multipart boundary is generated, so a Content-Type set anywhere else silently
/// makes the body unparseable upstream and surfaces only as a confusing 400.
/// </summary>
public class AiTranscribeContentTests
{
private static MultipartFormDataContent Build(
string username = "good-karma",
string durationMs = "30000",
string? idem = "abcd1234efgh",
string? fileName = "clip.m4a",
string? contentType = "audio/mp4")
{
var audio = new MemoryStream(Encoding.UTF8.GetBytes("fake audio bytes"));
return PrivateApi.BuildTranscribeContent(
username, durationMs, idem, audio, fileName, contentType);
}

private static async Task<string> Render(MultipartFormDataContent content)
{
return await content.ReadAsStringAsync();
}

[Fact]
public async Task UsIsTheAuthenticatedCallerNotAClientValue()
{
using var content = Build(username: "good-karma");
var body = await Render(content);

Assert.Contains("name=us", body.Replace("\"", ""));
Assert.Contains("good-karma", body);
}

[Fact]
public async Task CarriesDurationAndIdempotencyKey()
{
using var content = Build(durationMs: "45000", idem: "abcd1234efgh");
var body = await Render(content);

Assert.Contains("45000", body);
Assert.Contains("abcd1234efgh", body);
}

[Theory]
[InlineData(null)]
[InlineData("")]
public async Task OmitsAnEmptyIdempotencyKeyRatherThanSendingBlank(string? idem)
{
// Upstream validates the key against [A-Za-z0-9_-]{8,64}; a blank one is a 400,
// whereas an absent one is simply an un-deduplicated request.
using var content = Build(idem: idem);
var body = await Render(content);

Assert.DoesNotContain("idempotency_key", body);
}

[Fact]
public async Task SendsTheAudioPartWithItsFilename()
{
using var content = Build(fileName: "clip.m4a");
var body = await Render(content);

Assert.Contains("name=audio", body.Replace("\"", ""));
Assert.Contains("clip.m4a", body);
Assert.Contains("fake audio bytes", body);
}

[Fact]
public async Task FallsBackToAFilenameWhenTheClientSendsNone()
{
using var content = Build(fileName: null);
var body = await Render(content);

Assert.Contains("name=audio", body.Replace("\"", ""));
}

[Fact]
public void ContentTypeCarriesAGeneratedBoundary()
{
using var content = Build();

var mediaType = content.Headers.ContentType;
Assert.NotNull(mediaType);
Assert.Equal("multipart/form-data", mediaType!.MediaType);

var boundary = mediaType.Parameters
.FirstOrDefault(p => p.Name.Equals("boundary", StringComparison.OrdinalIgnoreCase));
Assert.NotNull(boundary);
Assert.False(string.IsNullOrWhiteSpace(boundary!.Value));
}

[Fact]
public async Task TolerantOfAMissingAudioContentType()
{
// expo-audio and MediaRecorder do not always label the part.
using var content = Build(contentType: null);
var body = await Render(content);

Assert.Contains("fake audio bytes", body);
}

[Theory]
// A bare token with no subtype, a trailing separator, and a broken parameter --
// all things a client can put in a multipart part header.
[InlineData("audio")]
[InlineData("audio/")]
[InlineData("audio/mp4;")]
[InlineData("audio/mp4; codecs=")]
[InlineData("not a media type at all")]
[InlineData("///")]
public async Task AMalformedContentTypeIsDroppedRatherThanThrowing(string contentType)
{
// MediaTypeHeaderValue.Parse throws FormatException on these, and the throw
// would escape the handler's form-reading catch and become a 500 from the
// global middleware. The upload itself is fine, so the label is dropped and
// the request proceeds.
var ex = Record.Exception(() =>
{
using var content = Build(contentType: contentType);
});
Assert.Null(ex);

using var built = Build(contentType: contentType);
var body = await Render(built);
Assert.Contains("fake audio bytes", body);
}

[Fact]
public async Task AValidContentTypeIsStillForwarded()
{
// The permissive path above must not have made this a no-op.
using var content = Build(contentType: "audio/mp4");
var body = await Render(content);

Assert.Contains("audio/mp4", body);
}
}
116 changes: 116 additions & 0 deletions dotnet/EcencyApi/Handlers/PrivateApi.Misc.cs
Original file line number Diff line number Diff line change
Expand Up @@ -613,4 +613,120 @@ public static async Task AiAssist(HttpContext ctx)
// AI assist generation can take a long time; keep it long.
await Upstream.Pipe(ApiClient.ApiRequest("ai-assist", HttpMethod.Post, null, data, null, 120000), ctx);
}

public static async Task AiTranscribePrice(HttpContext ctx)
{
var body = await ctx.ReadBody();
var username = await ValidateCode(body);
if (username == null)
{
await ctx.SendText(401, "Unauthorized");
return;
}
await Upstream.Pipe(
ApiClient.ApiRequest($"ai-transcribe-price?us={username}", HttpMethod.Get), ctx);
}

/// <summary>
/// Dictation. Unlike every other private-api route this one carries a file, so the
/// request is multipart/form-data rather than JSON and the auth code arrives as a
/// form field instead of a JSON property.
///
/// `us` is taken from the validated code and never from the client, matching
/// AiAssist: the upstream bills whoever `us` names, so accepting it from the body
/// would let a caller spend someone else's Points.
/// </summary>
public static async Task AiTranscribe(HttpContext ctx)
{
if (!ctx.Request.HasFormContentType)
{
await ctx.SendText(400, "Expected multipart/form-data");
return;
}

IFormCollection form;
try
{
form = await ctx.Request.ReadFormAsync();
}
catch (Exception e)
{
// Malformed multipart, or a body past Kestrel's limit.
Console.Error.WriteLine($"aiTranscribe(): unreadable form: {e.Message}");
await ctx.SendText(400, "Bad Request");
return;
}

// ValidateCode takes the JSON body shape, so lift the form field into it and
// reuse the one implementation rather than growing a second auth path.
var codeBody = new JsonObject { ["code"] = form["code"].ToString() };
var username = await ValidateCode(codeBody);
if (username == null)
{
await ctx.SendText(401, "Unauthorized");
return;
}

var audio = form.Files.GetFile("audio");
if (audio == null)
{
await ctx.SendText(400, "Missing audio");
return;
}

await using var audioStream = audio.OpenReadStream();
using var content = BuildTranscribeContent(
username,
form["duration_ms"].ToString(),
form["idempotency_key"].ToString(),
audioStream,
audio.FileName,
audio.ContentType);

// Transcription is a vendor round trip on top of the upload; keep it long,
// matching ai-assist and ai-image-generate.
await Upstream.Pipe(ApiClient.ApiMultipartRequest("ai-transcribe", content, 120000), ctx);
}

/// <summary>
/// Builds the upstream multipart body. Split out from the handler so the part it
/// gets wrong-once-and-badly is testable: `us` must be the caller resolved from the
/// signed code, never a value the client supplied, because upstream bills whoever
/// `us` names.
/// </summary>
public static MultipartFormDataContent BuildTranscribeContent(
string username,
string durationMs,
string? idempotencyKey,
Stream audio,
string? fileName,
string? contentType)
{
var content = new MultipartFormDataContent();
content.Add(new StringContent(username), "us");
content.Add(new StringContent(durationMs), "duration_ms");

// Omit rather than send empty: upstream treats an empty key as absent anyway,
// and sending "" would fail its [A-Za-z0-9_-]{8,64} validator with a 400.
if (!string.IsNullOrEmpty(idempotencyKey))
{
content.Add(new StringContent(idempotencyKey), "idempotency_key");
}

var fileContent = new StreamContent(audio);
// TryParse, not Parse. This value is client-controlled, and Parse throws
// FormatException on malformed input -- which escapes the handler's
// form-reading catch and surfaces as a 500 from the global middleware. A
// label we cannot parse is not worth failing an otherwise valid upload over,
// so it is dropped exactly like an absent one; upstream identifies the audio
// from its contents regardless.
if (!string.IsNullOrEmpty(contentType) &&
System.Net.Http.Headers.MediaTypeHeaderValue.TryParse(contentType, out var parsedType))
{
fileContent.Headers.ContentType = parsedType;
}
content.Add(fileContent, "audio", string.IsNullOrEmpty(fileName) ? "audio" : fileName);
Comment thread
coderabbitai[bot] marked this conversation as resolved.

return content;
}
}
2 changes: 2 additions & 0 deletions dotnet/EcencyApi/Handlers/Routes.cs
Original file line number Diff line number Diff line change
Expand Up @@ -157,6 +157,8 @@ public static void Map(WebApplication app)
app.MapPost("/private-api/ai-generate-image", PrivateApi.AiGenerateImage);
app.MapPost("/private-api/ai-assist-price", PrivateApi.AiAssistPrice);
app.MapPost("/private-api/ai-assist", PrivateApi.AiAssist);
app.MapPost("/private-api/ai-transcribe-price", PrivateApi.AiTranscribePrice);
app.MapPost("/private-api/ai-transcribe", PrivateApi.AiTranscribe);
Comment on lines +160 to +161

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Register the new routes as parity divergences

Adding these routes causes the legacy-Node parity run to report six deterministic failures: dotnet/parity/driver.py's load_routes and build_catalog automatically generate ::min, ::pop, and ::badcode JSON probes for each new POST route, while the reference image has neither route and returns 404 instead of the candidate's 400/401. Because none of those case IDs are in KNOWN_DIVERGENCES, every parity comparison now fails before it can identify unintended regressions; add exclusions for the intentional additive routes or otherwise teach the harness how to classify them.

Useful? React with 👍 / 👎.

app.MapPost("/private-api/usr-activity", PrivateApi.Activities);
app.MapPost("/private-api/get-game", PrivateApi.GameGet);
app.MapPost("/private-api/post-game", PrivateApi.GamePost);
Expand Down
38 changes: 31 additions & 7 deletions dotnet/EcencyApi/Infrastructure/ApiClient.cs
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,23 @@ public static Task<UpstreamResponse> ApiRequest(
JsonNode? payload = null,
IEnumerable<KeyValuePair<string, string?>>? query = null,
int timeoutMs = Upstream.DefaultTimeoutMs)
{
var headers = RequiredAuthHeaders();
if (extraHeaders != null)
{
headers.AddRange(extraHeaders);
}

return Upstream.BaseApiRequest(
$"{Config.PrivateApiAddr}/{endpoint}", method, headers, payload, query, timeoutMs);
}

/// <summary>
/// PRIVATE_API_AUTH headers every upstream call carries. Throws the same way the
/// Node version failed when they can't be built, so a misconfigured deployment is
/// loud rather than silently unauthenticated.
/// </summary>
private static List<KeyValuePair<string, string>> RequiredAuthHeaders()
{
var apiAuth = MakeApiAuth();
if (apiAuth == null)
Expand All @@ -76,19 +93,26 @@ public static Task<UpstreamResponse> ApiRequest(
throw new ApiAuthException();
}

var url = $"{Config.PrivateApiAddr}/{endpoint}";

var headers = new List<KeyValuePair<string, string>>();
foreach (var kv in apiAuth)
{
headers.Add(kv);
}
if (extraHeaders != null)
{
headers.AddRange(extraHeaders);
}
return headers;
}

return Upstream.BaseApiRequest(url, method, headers, payload, query, timeoutMs);
/// <summary>
/// multipart/form-data variant of ApiRequest, for endpoints carrying a file.
/// Applies the same PRIVATE_API_AUTH headers and fails the same way when they
/// can't be built.
/// </summary>
public static Task<UpstreamResponse> ApiMultipartRequest(
string endpoint,
MultipartFormDataContent content,
int timeoutMs = Upstream.DefaultTimeoutMs)
{
return Upstream.BaseMultipartRequest(
$"{Config.PrivateApiAddr}/{endpoint}", content, RequiredAuthHeaders(), timeoutMs);
}

/// <summary>fetchPromotedEntries + getPromotedEntries (5-minute cached, shuffled).</summary>
Expand Down
Loading
Loading