Files
SocialPub/PrivaPub.Tests/Support/Host/ClientApiHelpers.cs
T

166 lines
7.4 KiB
C#
Raw Normal View History

2026-10-03 11:49:55 +02:00
using MongoDB.Bson;
using MongoDB.Driver;
using MongoDB.Entities;
using PrivaPub.Models.User;
using System.Net;
using System.Net.Http.Json;
using System.Text;
using System.Text.Json.Nodes;
using System.Text.RegularExpressions;
namespace PrivaPub.Tests.Support.Host
{
public sealed record OAuthApp(string ClientId, string ClientSecret, string Scopes)
{
public string Query(string extra = default) =>
$"client_id={Uri.EscapeDataString(ClientId)}&redirect_uri={Uri.EscapeDataString(ClientApi.OutOfBand)}&response_type=code&scope={Uri.EscapeDataString(Scopes)}"
+ (extra == default ? string.Empty : "&" + extra);
public string ReturnUrl(string extra = default) => "/oauth/authorize?" + Query(extra);
}
public static partial class ClientApi
{
public const string OutOfBand = "urn:ietf:wg:oauth:2.0:oob";
public static HttpClient ClientAt(this PrivaPubHost host, string address, bool cookies = false)
{
var client = host.Client(cookies);
client.DefaultRequestHeaders.Remove(PrivaPubHost.ClientHeader);
client.DefaultRequestHeaders.Add(PrivaPubHost.ClientHeader, address);
return client;
}
public static async Task<JsonObject> JsonBody(this HttpResponseMessage response) =>
JsonNode.Parse(await response.Content.ReadAsStringAsync())!.AsObject();
public static async Task<JsonArray> JsonItems(this HttpResponseMessage response) =>
JsonNode.Parse(await response.Content.ReadAsStringAsync())!.AsArray();
public static Task<HttpResponseMessage> PostJson(this HttpClient client, string path, object body) =>
client.PostAsJsonAsync(path, body);
public static Task<HttpResponseMessage> Form(this HttpClient client, string path, params (string Key, string Value)[] fields) =>
client.PostAsync(path, new FormUrlEncodedContent(fields.Select(f => new KeyValuePair<string, string>(f.Key, f.Value))));
public static async Task<OAuthApp> RegisterApp(this HttpClient client, string scopes = "read write follow")
{
var response = await client.Form("/api/v1/apps", ("client_name", "privapub-tests"), ("redirect_uris", OutOfBand), ("scopes", scopes));
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
var app = await response.JsonBody();
return new OAuthApp(app["client_id"]!.GetValue<string>(), app["client_secret"]!.GetValue<string>(), scopes);
}
public static async Task<HttpResponseMessage> SignIn(this HttpClient client, Root root, string returnUrl, string password = default)
{
var login = await client.GetStringAsync("/oauth/login?returnUrl=" + Uri.EscapeDataString(returnUrl));
return await client.PostAsync("/oauth/login", new FormUrlEncodedContent(new Dictionary<string, string>
{
["returnUrl"] = returnUrl,
["__RequestVerificationToken"] = Antiforgery(login),
["userName"] = root.UserName,
["password"] = password ?? root.Password
}));
}
public static string Antiforgery(string page) => AntiforgeryToken().Match(page).Groups[1].Value;
public static async Task<List<KeyValuePair<string, string>>> Choice(this HttpClient client, OAuthApp app)
{
var page = await client.GetStringAsync(app.ReturnUrl() + "&signed_in=1");
return HiddenInput().Matches(page).Select(m => new KeyValuePair<string, string>(m.Groups[1].Value, WebUtility.HtmlDecode(m.Groups[2].Value))).ToList();
}
public static Task<HttpResponseMessage> Decide(this HttpClient client, IEnumerable<KeyValuePair<string, string>> choice, string avatarId, string decision = "allow") =>
client.PostAsync("/oauth/authorize", new FormUrlEncodedContent(choice.Append(new("avatarId", avatarId)).Append(new("decision", decision))));
public static async Task<HttpResponseMessage> Choose(this HttpClient client, OAuthApp app, string avatarId, string decision = "allow") =>
await client.Decide(await client.Choice(app), avatarId, decision);
public static string CodeIn(string page) => CodeElement().Match(page) is { Success: true } match ? match.Groups[1].Value : default;
public static async Task<string> Code(this HttpClient client, OAuthApp app, Persona persona)
{
Assert.Equal(HttpStatusCode.Redirect, (await client.SignIn(persona.Root, app.ReturnUrl())).StatusCode);
var code = CodeIn(await (await client.Choose(app, persona.Id)).Content.ReadAsStringAsync());
Assert.False(string.IsNullOrEmpty(code));
return code;
}
public static Task<HttpResponseMessage> Exchange(this HttpClient client, OAuthApp app, string code) =>
client.Form("/oauth/token", ("grant_type", "authorization_code"), ("code", code), ("client_id", app.ClientId),
("client_secret", app.ClientSecret), ("redirect_uri", OutOfBand));
public static async Task<string> Token(this HttpClient client, OAuthApp app, Persona persona)
{
var response = await client.Exchange(app, await client.Code(app, persona));
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
return (await response.JsonBody())["access_token"]!.GetValue<string>();
}
public static async Task<JsonObject> Group(this PrivaPubHost host, Persona owner, bool community, string password = default, string name = "group")
{
using var client = host.As(owner.Root.Jwt);
var response = await client.PostJson("/clientapi/group/insert", new
{
avatarId = owner.Id,
userName = $"{name}{Guid.NewGuid():N}"[..20],
name,
isCommunity = community,
invitationPassword = password
});
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
return await response.JsonBody();
}
public static async Task Ban(string rootId, bool banned = true) =>
await DB.Default.Update<RootUser>().MatchID(rootId).Modify(u => u.IsBanned, banned).ExecuteAsync();
public static async Task<JsonObject> Follow(this PrivaPubHost host, RemoteActor follower, string origin, string userName)
{
var follow = new JsonObject
{
["id"] = $"{origin}/follows/{Guid.NewGuid():N}",
["type"] = "Follow",
["actor"] = follower.Id,
["object"] = $"{PrivaPubHost.Base}/peasants/{userName}"
};
using var client = host.Client();
Assert.Equal(HttpStatusCode.Accepted, (await client.SendAsync(follower.SignedPost($"/peasants/{userName}/mouth", follow))).StatusCode);
Assert.Equal(1, await host.RunInbox(follow["id"]!.GetValue<string>()));
return follow;
}
public static async Task<List<BsonDocument>> StoredTokens(string clientId)
{
var database = DB.Default.Database();
var application = await database.GetCollection<BsonDocument>("openiddict.applications")
.Find(Builders<BsonDocument>.Filter.Eq("client_id", clientId)).FirstAsync();
return await database.GetCollection<BsonDocument>("openiddict.tokens")
.Find(Builders<BsonDocument>.Filter.Eq("application_id", application["_id"])).ToListAsync();
}
public static bool IsAccessToken(BsonDocument token) =>
token.GetValue("type", BsonNull.Value) is { IsString: true } type && type.AsString.EndsWith("access_token", StringComparison.Ordinal);
public static string JwtPayload(string jwt)
{
var parts = jwt.Split('.');
if (parts.Length != 3)
return string.Empty;
var segment = parts[1].Replace('-', '+').Replace('_', '/');
return Encoding.UTF8.GetString(Convert.FromBase64String(segment.PadRight(segment.Length + (4 - segment.Length % 4) % 4, '=')));
}
[GeneratedRegex("name=\"__RequestVerificationToken\" type=\"hidden\" value=\"([^\"]*)\"")]
private static partial Regex AntiforgeryToken();
[GeneratedRegex("<input type=\"hidden\" name=\"([^\"]*)\" value=\"([^\"]*)\"")]
private static partial Regex HiddenInput();
[GeneratedRegex("<code>([^<]*)</code>")]
private static partial Regex CodeElement();
}
}