Mastodon's streaming API: /api/v1/streaming as a WebSocket (streams
subscribed in the URL or by message) and /api/v1/streaming/{stream} as
server-sent events, with health and the URL advertised. The user stream
tells posts reaching the persona's home (not those an exclusive list keeps
apart, which its list stream tells), notifications, edits and deletions;
public, hashtag and list streams tell what belongs in them. An in-process
hub carries ids only; each connection maps a post or a notification for its
own persona as it sends it, so nothing it may not see, or whose author it
blocked or muted, goes out. A deletion reaches only the streams that showed
the post. The token comes as access_token, header or WebSocket protocol.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LsXgEaXee4GCU1hwYgPJXw
130 lines
6.0 KiB
C#
130 lines
6.0 KiB
C#
using PrivaPub.Api.Mastodon.Controllers;
|
|
using PrivaPub.Tests.Support;
|
|
using PrivaPub.Tests.Support.Host;
|
|
|
|
using System.Net;
|
|
|
|
namespace PrivaPub.Tests.Http
|
|
{
|
|
[Trait("Category", "Integration")]
|
|
public sealed class MastodonInstanceTests : IAsyncLifetime
|
|
{
|
|
PrivaPubHost _host;
|
|
|
|
public async ValueTask InitializeAsync()
|
|
{
|
|
Assert.SkipUnless(MongoFixture.Enabled, MongoFixture.Skip);
|
|
_host = await PrivaPubHost.Shared();
|
|
}
|
|
|
|
public ValueTask DisposeAsync() => ValueTask.CompletedTask;
|
|
|
|
[Fact]
|
|
public async Task Both_instance_documents_advertise_mastodon_4_2_and_the_domain()
|
|
{
|
|
var anonymous = _host.Client();
|
|
|
|
var v1 = (await anonymous.Get("/api/v1/instance")).Ok();
|
|
Assert.Equal("4.2.0 (compatible; PrivaPub)", v1.Body.Text("version"));
|
|
Assert.Equal(InstanceController.Version, v1.Body.Text("version"));
|
|
Assert.Equal(PrivaPubHost.Host, v1.Body.Text("uri"));
|
|
Assert.True(v1.Body.Flag("registrations"));
|
|
Assert.True(v1.Body.Flag("invites_enabled"));
|
|
Assert.True(v1.Body["stats"].Number("user_count") >= 0);
|
|
Assert.Equal(5000, v1.Body["configuration"]!["statuses"].Number("max_characters"));
|
|
Assert.Equal(4, v1.Body["configuration"]!["polls"].Number("max_options"));
|
|
Assert.Contains("image/jpeg", v1.Body["configuration"]!["media_attachments"]!["supported_mime_types"]!.AsArray().Select(t => t!.GetValue<string>()));
|
|
Assert.Empty(v1.Body["rules"]!.AsArray());
|
|
Assert.Null(v1.Body["contact_account"]);
|
|
Assert.True(v1.Body.AsObject().ContainsKey("contact_account"));
|
|
|
|
var v2 = (await anonymous.Get("/api/v2/instance")).Ok();
|
|
Assert.Equal(InstanceController.Version, v2.Body.Text("version"));
|
|
Assert.Equal(PrivaPubHost.Host, v2.Body.Text("domain"));
|
|
Assert.Equal($"wss://{PrivaPubHost.Host}", v2.Body["configuration"]!["urls"]!.Text("streaming"));
|
|
Assert.Equal($"wss://{PrivaPubHost.Host}", v1.Body["urls"]!.Text("streaming_api"));
|
|
Assert.Equal(PrivaPub.Api.Mastodon.Controllers.StatusesController.MaxPins, v2.Body["configuration"]!["accounts"].Number("max_pinned_statuses"));
|
|
Assert.Contains("image/avif", v2.Body["configuration"]!["media_attachments"]!["supported_mime_types"]!.AsArray().Select(t => t!.GetValue<string>()));
|
|
Assert.True(v2.Body["registrations"].Flag("enabled"));
|
|
Assert.True((await anonymous.Get("/nodeinfo/2.1")).Ok().Body.Flag("openRegistrations"));
|
|
Assert.False(v2.Body["configuration"]!["translation"].Flag("enabled"));
|
|
Assert.Equal(7, v2.Body["api_versions"].Number("mastodon"));
|
|
|
|
var signedIn = await _host.Mastodon("reader");
|
|
Assert.Equal(InstanceController.Version, (await signedIn.Client.Get("/api/v1/instance")).Ok().Body.Text("version"));
|
|
}
|
|
|
|
[Fact]
|
|
public async Task The_instance_side_routes_answer_without_a_login()
|
|
{
|
|
var anonymous = _host.Client();
|
|
|
|
Assert.Empty((await anonymous.Get("/api/v1/instance/peers")).Ok().Array);
|
|
Assert.Empty((await anonymous.Get("/api/v1/instance/activity")).Ok().Array);
|
|
Assert.Empty((await anonymous.Get("/api/v1/instance/rules")).Ok().Array);
|
|
var extended = (await anonymous.Get("/api/v1/instance/extended_description")).Ok();
|
|
Assert.StartsWith("<p>", extended.Body.Text("content"));
|
|
Assert.EndsWith("T00:00:00.000Z", extended.Body.Text("updated_at"));
|
|
}
|
|
|
|
[Theory]
|
|
[InlineData("/api/v1/custom_emojis")]
|
|
[InlineData("/api/v1/announcements")]
|
|
public async Task Public_stub_routes_answer_empty_lists_to_anyone(string path)
|
|
{
|
|
Assert.Empty((await _host.Client().Get(path)).Ok().Array);
|
|
}
|
|
|
|
[Theory]
|
|
[InlineData("/api/v1/suggestions")]
|
|
[InlineData("/api/v2/suggestions")]
|
|
[InlineData("/api/v1/endorsements")]
|
|
[InlineData("/api/v1/featured_tags")]
|
|
public async Task Signed_in_stub_routes_answer_empty_lists(string path)
|
|
{
|
|
var reader = await _host.Mastodon("reader");
|
|
|
|
Assert.Empty((await reader.Client.Get(path)).Ok().Array);
|
|
Assert.Equal(HttpStatusCode.Unauthorized, (await _host.Client().Get(path)).Status);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Apps_are_registered_validated_and_verified()
|
|
{
|
|
var anonymous = _host.Client();
|
|
|
|
var app = (await anonymous.Post("/api/v1/apps", ("client_name", "tusky-ish"), ("redirect_uris", "urn:ietf:wg:oauth:2.0:oob"), ("scopes", "read write"),
|
|
("website", "https://example.com"))).Ok();
|
|
|
|
Assert.Equal("tusky-ish", app.Body.Text("name"));
|
|
Assert.Equal(new[] { "read", "write" }, app.Body["scopes"]!.AsArray().Select(s => s!.GetValue<string>()));
|
|
Assert.False(string.IsNullOrEmpty(app.Body.Text("client_id")));
|
|
Assert.False(string.IsNullOrEmpty(app.Body.Text("client_secret")));
|
|
Assert.Equal(HttpStatusCode.UnprocessableEntity, (await anonymous.Post("/api/v1/apps", ("redirect_uris", "urn:ietf:wg:oauth:2.0:oob"))).Status);
|
|
Assert.Equal(HttpStatusCode.UnprocessableEntity, (await anonymous.Post("/api/v1/apps", ("client_name", "x"), ("redirect_uris", "javascript:alert(1)"))).Status);
|
|
Assert.Equal(HttpStatusCode.UnprocessableEntity, (await anonymous.Post("/api/v1/apps", ("client_name", "x"))).Status);
|
|
|
|
var reader = await _host.Mastodon("reader");
|
|
var verified = (await reader.Client.Get("/api/v1/apps/verify_credentials")).Ok();
|
|
Assert.Equal("privapub-tests", verified.Body.Text("name"));
|
|
Assert.Equal(HttpStatusCode.Unauthorized, (await anonymous.Get("/api/v1/apps/verify_credentials")).Status);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Preferences_follow_the_persona_and_push_has_no_subscription()
|
|
{
|
|
var reader = await _host.Mastodon("reader", scopes: "read write follow push");
|
|
(await reader.Client.Patch("/api/v1/accounts/update_credentials", ("source[privacy]", "unlisted"), ("source[sensitive]", "true"))).Ok();
|
|
|
|
var preferences = (await reader.Client.Get("/api/v1/preferences")).Ok();
|
|
|
|
Assert.Equal("unlisted", preferences.Body.Text("posting:default:visibility"));
|
|
Assert.True(preferences.Body.Flag("posting:default:sensitive"));
|
|
Assert.False(preferences.Body.Flag("reading:expand:spoilers"));
|
|
var push = await reader.Client.Get("/api/v1/push/subscription");
|
|
Assert.Equal(HttpStatusCode.NotFound, push.Status);
|
|
Assert.Equal("Record not found", push.Body.Text("error"));
|
|
}
|
|
}
|
|
}
|