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 JsonBody(this HttpResponseMessage response) => JsonNode.Parse(await response.Content.ReadAsStringAsync())!.AsObject(); public static async Task JsonItems(this HttpResponseMessage response) => JsonNode.Parse(await response.Content.ReadAsStringAsync())!.AsArray(); public static Task PostJson(this HttpClient client, string path, object body) => client.PostAsJsonAsync(path, body); public static Task Form(this HttpClient client, string path, params (string Key, string Value)[] fields) => client.PostAsync(path, new FormUrlEncodedContent(fields.Select(f => new KeyValuePair(f.Key, f.Value)))); public static async Task 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(), app["client_secret"]!.GetValue(), scopes); } public static async Task 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 { ["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>> Choice(this HttpClient client, OAuthApp app) { var page = await client.GetStringAsync(app.ReturnUrl() + "&signed_in=1"); return HiddenInput().Matches(page).Select(m => new KeyValuePair(m.Groups[1].Value, WebUtility.HtmlDecode(m.Groups[2].Value))).ToList(); } public static Task Decide(this HttpClient client, IEnumerable> 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 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 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 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 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(); } public static async Task 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().MatchID(rootId).Modify(u => u.IsBanned, banned).ExecuteAsync(); public static async Task 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())); return follow; } public static async Task> StoredTokens(string clientId) { var database = DB.Default.Database(); var application = await database.GetCollection("openiddict.applications") .Find(Builders.Filter.Eq("client_id", clientId)).FirstAsync(); return await database.GetCollection("openiddict.tokens") .Find(Builders.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("([^<]*)")] private static partial Regex CodeElement(); } }