96 integration tests through PrivaPubHost, the real pipeline end to end:
- OAuth: the token's subject is the persona, and neither the token response,
verify_credentials nor the stored token entries name the root. A wrong password shows
an error and sets no login cookie; a login without the antiforgery token is a 400; the
return address never leaves the site; deny answers access_denied with no code; another
root's persona re-renders the choice with no code; a banned root is sent back to the
login and a code issued before the ban buys no token; force_login asks again; a code
works once and its reuse revokes the token it bought; password and refresh_token
grants are refused; a client_credentials token gets 401 on user routes; a read-only
token gets 403 with a Mastodon error on POST /api/v1/statuses; follow covers
read:follows; revoke works; the login and authorize pages send their CSP and no-store;
the 11th /oauth/login from one address in a minute is a 429.
- Accounts: sign-up, duplicates in any case, invalid models answer 400 with a message,
login and logout, recovery email, settings, password change, invitation sign-up and
login (refusing a persona named after the login), recovery without an email, through
an unreachable mail server, with a wrong and with a valid code, token refresh, the 11th
sign-up from one address, expired, garbage and foreign-key JWTs.
- Personas: a rootId in the body is ignored, the username regex and reserved names hold,
personas and groups share ReservedName, an update delivers Update{Person} to followers,
PublishedOn and the id's day fall within two weeks before creation, the list holds only
one's own personas, another root's persona cannot be updated.
- Groups: communities and circles are created, joining takes the code and the password,
members leave and owners cannot, a remote follow request becomes a member only on
approval, a circle never shows in lookup, account by id, v2 search or /@name, and its
/flock and /wardens answer 404 unsigned and to non-members, 200 to a member's signed GET.
- Moderation (Exclusive, it suspends localhost): ban, unban and remove; non-admins get
403; reports are listed without the reporter and resolved; domain blocks are inserted,
listed and deleted, bad domains refused, and a suspended server's delivery is answered
202 and kept nowhere; the data endpoints.
- AdminCommands: exit codes 0, 1 and 2 and the resulting policies.
Fixed:
- A banned or removed root kept using /clientapi with its JWT until it expired: only /api
re-checked the root. JwtEvents.TokenValidated now loads the root and fails the request
when it is banned or deleted, and takes the policy claims from the database, so a
demoted admin loses admin at once (and a promoted one gains it).
- The 401 and 403 bodies JwtEvents writes were PascalCase while every other /clientapi
answer is camelCase; they now use the web defaults.
- /clientapi/user/sniff/again (token refresh) answered an empty 200; it now answers a
fresh JwtUser, like login.
- Password recovery answered SMTP reply codes as HTTP statuses (421, 454, 554, and 550 for
an invalid address); a mail server failure is now 503 and an invalid address 400.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ELjqpznMFMNrJoJUj6K5p2
167 lines
7.4 KiB
C#
167 lines
7.4 KiB
C#
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();
|
|
}
|
|
}
|