Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ab526ed291 | ||
|
|
aa7e43a61e | ||
|
|
e29da1b47a | ||
|
|
d4e9acdb79 |
No files matched your search
@@ -281,13 +281,15 @@ cd /var/www/privapub.thepra.dev && sudo -u www-data ASPNETCORE_ENVIRONMENT=Produ
|
||||
array; Markdown `content`; missing `mediaType`s inferred; thumbnails from any of their five places; and the typed
|
||||
`Link`, `Video`, `Audio` and `Event` details. A Mastodon API card is built from those, never by fetching the linked page
|
||||
(that fetch is the owner's decision 1, for P6).
|
||||
- **An `Update` is an edit only when its `updated` is newer than ours** (`RemoteEdits.IsEdit`; plain and community-wrapped
|
||||
- **An `Update` is an edit only when its `updated` is newer than ours**, or for a first edit no older and with the text
|
||||
actually changed, since GoToSocial's whole-second timestamps make a quick edit carry `updated == published` (`RemoteEdits.IsEdit`; plain and community-wrapped
|
||||
Updates both go through `RemoteEdits.Apply`, and deletes through `RemoteDeletes.Remove`). Otherwise it refreshes
|
||||
the poll, video, audio and event details and nothing else, and leaves no revision: Mastodon and Misskey refresh poll
|
||||
counts with bare Updates.
|
||||
- **A poll vote is a `Note` with a `name`, an `inReplyTo` that is a poll we hold, and no content.** `CreateHandler`
|
||||
hands it to `PollService.Receive` before anything else, so it never becomes a reply. Our votes on other servers' polls
|
||||
go only to the poll's author, without `published` (PieFed counts a vote only then).
|
||||
go only to the poll's author, without `published` (PieFed counts a vote only then). A `closed` in the future is the
|
||||
poll's end, not its closing: Akkoma sends an open poll's end only there (`ObjectShapes`).
|
||||
- **Link previews follow owner decision 1** (`Domain/Content/LinkPreviews.cs`): only public posts, queued on arrival with
|
||||
0–60 s of jitter, one cached `LinkPreview` per address for the whole server, never fetched when someone reads.
|
||||
`LinkPreviews.Wanted` is called where posts are saved (`CreateHandler`, `StoreContext`, `StatusService.Publish`).
|
||||
@@ -409,6 +411,27 @@ tools/pasture/run.sh down # removes e
|
||||
deliveries are checked through `/api/v1/accounts/:id/statuses` of the sender as Mastodon knows them, or
|
||||
`Status.exists?` through `rails runner`. Its actors are numbered (`/ap/users/<id>`), so look URIs up rather than
|
||||
build them. 49 checks; circle posts are an expected failure (see `docs/INTEROP.md`, Mastodon).
|
||||
- **Misskey (2026.10.0):** one container on the shared Postgres and Redis. A new Misskey federates with nobody
|
||||
(`federation: none`) until `admin/update-meta` says `all`, which `misskey_up` does. Its API is `POST /api/<endpoint>`
|
||||
with the token as `i` (`mk` in the scenario); `users/relation` answers a list, `users/notes` leaves replies out unless
|
||||
asked, and a user has one reaction per note. Never verify a delivery with `ap/show`: it fetches. 35 checks.
|
||||
- **Sharkey (2025.4.7):** `peers/sharkey.sh` is the Misskey peer under another name, image, database, Redis db and
|
||||
home (`/sharkey/.config`); `scenarios/sharkey.sh` runs Misskey's scenario with `MISSKEY_NAME=sharkey`, then follows
|
||||
again (Misskey's ends unfollowed and blocked) and checks edits both ways and the FEP-e232 quote tag. 40 checks.
|
||||
- **Akkoma (3.20.1):** no official image, so `images/akkoma` installs the OTP release (pinned by checksum; the "stable"
|
||||
zip moves, and a moved one fails the build) and `akkoma_up` builds it once. Its HTTP clients read the CA bundles
|
||||
shipped in the release (CAStore, certifi), so the entrypoint appends Caddy's CA there. `pleroma_ctl` passes its
|
||||
arguments on unquoted: no value may contain a space. Its Linkify never takes `@user@host.test` for a mention, so the
|
||||
scenario addresses alice with Pleroma's `to[]`; its API never reports a remote blocker as `blocked_by`, so the block is
|
||||
read from its `user_relationships`. 44 checks.
|
||||
- **Lemmy (1.0.0-beta.2):** the backend alone on the shared Postgres, its admin made by `setup` in the generated
|
||||
`config.hjson`. It trusts Caddy's CA through `SSL_CERT_FILE` and reaches the network through
|
||||
`DANGER_FEDERATION_ALLOW_LOCAL_IP=1`; 0.19 cannot join (its rustls trusts only its bundled roots). Its API is
|
||||
`/api/v4/<path>` with a bearer token (`lm` in the scenario), and `sort` values are lowercase. It logs no refused
|
||||
activity at `warn` (`LEMMY_LOG` sets `RUST_LOG`); the reason is in the 400's body. It answers our community's echo of
|
||||
its own activity and every bare `Announce{object}` 400 by design, and the echo is still needed (see
|
||||
`docs/INTEROP.md`, Lemmy). A new Lemmy never sends what it queued for a server before its send worker for that
|
||||
server started, so the scenario waits for that worker (`lm_worker`) before its first follow. 20 checks; relayed votes and a moderator's removal are expected failures (P7).
|
||||
- **Crawler:** `PRIVAPUB_ENV="Statistics__Crawler__Enabled=true Statistics__Crawler__Seeds__0=mastodon.test"
|
||||
run.sh up mastodon`, then `interop.sh crawler`. `PRIVAPUB_ENV` passes any setting to the PrivaPub container.
|
||||
- `run.sh up` replaces every container, Mongo included, so each run starts clean. To keep the data, republish into
|
||||
|
||||
@@ -65,6 +65,8 @@ namespace PrivaPub.Tests.Domain
|
||||
var sent = await _harness.Outgoing(follower.SharedInbox);
|
||||
Assert.Equal(new[] { "Create", "Update", "Delete" }, sent.Select(a => a["type"]!.GetValue<string>()));
|
||||
Assert.DoesNotContain(sent[0]["to"]!.AsArray(), n => n!.GetValue<string>().EndsWith("#Public"));
|
||||
// Pleroma reads an empty cc without a "/followers" address as a direct message
|
||||
Assert.Contains(sent[0]["object"]!["cc"]!.AsArray(), n => n!.GetValue<string>() == alice.Followers);
|
||||
Assert.Equal("cw", sent[1]["object"]!["summary"]!.GetValue<string>());
|
||||
Assert.NotNull(sent[1]["object"]!["updated"]);
|
||||
Assert.Equal(sent[0]["to"]!.ToJsonString(), sent[2]["to"]!.ToJsonString());
|
||||
|
||||
@@ -190,7 +190,7 @@ namespace PrivaPub.Tests.Federation
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Reads_polls_from_mastodon_misskey_and_closed_ones()
|
||||
public void Reads_polls_from_mastodon_misskey_akkoma_and_closed_ones()
|
||||
{
|
||||
var mastodon = Parse("""
|
||||
{ "id": "https://m.example/q/1", "type": "Question", "attributedTo": "https://m.example/u", "content": "?", "endTime": "2026-10-02T00:00:00Z", "votersCount": 7,
|
||||
@@ -204,6 +204,10 @@ namespace PrivaPub.Tests.Federation
|
||||
{ "id": "https://g.example/q/1", "type": "Question", "attributedTo": "https://g.example/u", "content": "?", "closed": "2026-09-30T12:00:00Z",
|
||||
"oneOf": [{ "type": "Note", "name": "p" }, { "type": "Note", "name": "q" }] }
|
||||
""");
|
||||
var akkoma = Parse("""
|
||||
{ "id": "https://a.example/objects/1", "type": "Question", "attributedTo": "https://a.example/users/u", "content": "?", "closed": "2099-01-01T00:00:00.000000Z",
|
||||
"oneOf": [{ "type": "Note", "name": "p" }, { "type": "Note", "name": "q" }] }
|
||||
""");
|
||||
|
||||
Assert.False(mastodon.Poll.Multiple);
|
||||
Assert.Equal(new[] { 4, 3 }, mastodon.Poll.Options.Select(o => o.Votes));
|
||||
@@ -212,6 +216,8 @@ namespace PrivaPub.Tests.Federation
|
||||
Assert.Equal(new[] { 2, 5 }, misskey.Poll.Options.Select(o => o.Votes));
|
||||
Assert.Null(misskey.Poll.VotersCount);
|
||||
Assert.Equal(new DateTime(2026, 9, 30, 12, 0, 0, DateTimeKind.Utc), closed.Poll.ClosedAt);
|
||||
Assert.Null(akkoma.Poll.ClosedAt);
|
||||
Assert.Equal(new DateTime(2099, 1, 1, 0, 0, 0, DateTimeKind.Utc), akkoma.Poll.ExpiresAt);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
using PrivaPub.Federation.Inbox;
|
||||
using PrivaPub.Federation.Objects;
|
||||
using PrivaPub.Models.Post;
|
||||
|
||||
namespace PrivaPub.Tests.Federation
|
||||
{
|
||||
public class RemoteEditsTests
|
||||
{
|
||||
static readonly DateTime Published = new(2026, 10, 3, 10, 40, 1, DateTimeKind.Utc);
|
||||
|
||||
static Post Stored(DateTime? editedAt = default) => new() { CreationDate = Published, EditedAt = editedAt, ContentHtml = "<p>before</p>" };
|
||||
|
||||
[Fact]
|
||||
public void A_newer_update_is_an_edit_and_an_update_without_a_date_is_a_refresh()
|
||||
{
|
||||
Assert.True(RemoteEdits.IsEdit(new NoteDocument { Updated = Published.AddSeconds(1), ContentHtml = "<p>before</p>" }, Stored()));
|
||||
Assert.False(RemoteEdits.IsEdit(new NoteDocument { ContentHtml = "<p>after</p>" }, Stored()));
|
||||
Assert.False(RemoteEdits.IsEdit(new NoteDocument { Updated = Published.AddSeconds(-1), ContentHtml = "<p>after</p>" }, Stored()));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void An_edit_in_the_second_the_post_was_published_counts_when_the_text_changed()
|
||||
{
|
||||
Assert.True(RemoteEdits.IsEdit(new NoteDocument { Updated = Published, ContentHtml = "<p>after</p>" }, Stored()));
|
||||
Assert.True(RemoteEdits.IsEdit(new NoteDocument { Updated = Published, ContentHtml = "<p>before</p>", SpoilerText = "now with a warning" }, Stored()));
|
||||
Assert.False(RemoteEdits.IsEdit(new NoteDocument { Updated = Published, ContentHtml = "<p>before</p>" }, Stored()));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void After_a_first_edit_only_a_newer_one_counts()
|
||||
{
|
||||
var edited = Published.AddMinutes(1);
|
||||
|
||||
Assert.False(RemoteEdits.IsEdit(new NoteDocument { Updated = edited, ContentHtml = "<p>again</p>" }, Stored(edited)));
|
||||
Assert.True(RemoteEdits.IsEdit(new NoteDocument { Updated = edited.AddSeconds(1), ContentHtml = "<p>again</p>" }, Stored(edited)));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -60,7 +60,8 @@ namespace PrivaPub.Tests.Http
|
||||
Assert.Equal(new[] { MastodonHelpers.Public }, Strings(unlistedNote["cc"]));
|
||||
var quietNote = creates[quiet.Text("uri")]["object"];
|
||||
Assert.Equal(new[] { followers }, Strings(quietNote["to"]));
|
||||
Assert.Empty(quietNote["cc"]!.AsArray());
|
||||
// named again in cc: Pleroma and Akkoma read an empty cc without a "/followers" address as a direct message
|
||||
Assert.Equal(new[] { followers }, Strings(quietNote["cc"]));
|
||||
var whisper = Assert.Single(await carol.Delivered(since));
|
||||
Assert.Equal(direct.Text("uri"), whisper["object"].Text("id"));
|
||||
Assert.Equal(new[] { carol.Id }, Strings(whisper["object"]!["to"]));
|
||||
|
||||
@@ -15,8 +15,15 @@ namespace PrivaPub.Federation.Inbox
|
||||
{
|
||||
public const int MaxRevisions = 20;
|
||||
|
||||
//newer than what we hold; or, for a first edit, no older and actually different, because GoToSocial's timestamps are
|
||||
//whole seconds and an edit made in the second the post was published carries updated == published
|
||||
public static bool IsEdit(NoteDocument note, PostEntity post) =>
|
||||
note.Updated is { } updated && updated > (post.EditedAt ?? post.CreationDate);
|
||||
note.Updated is { } updated
|
||||
&& (updated > (post.EditedAt ?? post.CreationDate)
|
||||
|| post.EditedAt == default && updated >= post.CreationDate && Changed(note, post));
|
||||
|
||||
static bool Changed(NoteDocument note, PostEntity post) =>
|
||||
note.ContentHtml != post.ContentHtml || note.SpoilerText != post.SpoilerText || note.Title != post.Title || note.Sensitive != post.HasContentWarning;
|
||||
|
||||
public static async Task<bool> Apply(PostEntity post, NoteDocument note, string activityId, ILocalActorService localActors, IObjectRecords records,
|
||||
IQuoteService quotes, CancellationToken token)
|
||||
|
||||
@@ -137,11 +137,18 @@ namespace PrivaPub.Federation.Objects
|
||||
JsonValue value when value.TryGetValue<string>(out var at) => Moment(at),
|
||||
_ => default
|
||||
};
|
||||
var expiresAt = Moment(Value(note, "endTime"));
|
||||
// Pleroma and Akkoma put an open poll's end in `closed`, often without `endTime`: it is closed once that has passed
|
||||
if (closedAt > DateTime.UtcNow)
|
||||
{
|
||||
expiresAt ??= closedAt;
|
||||
closedAt = default;
|
||||
}
|
||||
return new PostPoll
|
||||
{
|
||||
Options = options,
|
||||
Multiple = multiple,
|
||||
ExpiresAt = Moment(Value(note, "endTime")),
|
||||
ExpiresAt = expiresAt,
|
||||
ClosedAt = closedAt,
|
||||
VotersCount = Int(note, "votersCount")
|
||||
};
|
||||
|
||||
@@ -40,6 +40,8 @@ namespace PrivaPub.Federation.Outbox
|
||||
["audience"] = group.Uri,
|
||||
["object"] = embedded
|
||||
};
|
||||
// Every follower, the author's own server included: Lemmy 1.0 keeps its user's post pending until the community
|
||||
// announces it back, and clears that before it answers the echo 400 ("Object is not remote").
|
||||
await _delivery.EnqueueToFollowers(group, announce, token);
|
||||
if (isNewPost && !string.IsNullOrEmpty(objectUri))
|
||||
await _delivery.EnqueueToFollowers(group, ActivityPubRenderer.Announce(group, objectUri, $"announce-object-{key}"), token);
|
||||
|
||||
@@ -138,7 +138,9 @@ namespace PrivaPub.Federation.Rendering
|
||||
{
|
||||
PostVisibility.Circle when group != default => (new JsonArray(group.Uri, group.Flock), new JsonArray(mentions)),
|
||||
PostVisibility.Unlisted => (new JsonArray(author.Followers), new JsonArray(mentions.Prepend(Public).ToArray())),
|
||||
PostVisibility.FollowersOnly => (new JsonArray(author.Followers), new JsonArray(mentions)),
|
||||
// Pleroma and Akkoma call a post private only if a `to` contains "/followers" or its cc is not empty, and ours
|
||||
// are /groupies: with an empty cc every followers-only post would be a DM there. Naming them again says nothing new.
|
||||
PostVisibility.FollowersOnly => (new JsonArray(author.Followers), new JsonArray(mentions.Append(author.Followers).ToArray())),
|
||||
PostVisibility.Direct => (new JsonArray(mentions), new JsonArray()),
|
||||
_ => (new JsonArray(Public), new JsonArray(mentions.Prepend(author.Followers).ToArray()))
|
||||
};
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
using Microsoft.AspNetCore.RateLimiting;
|
||||
using Microsoft.Extensions.Options;
|
||||
|
||||
using PrivaPub.Federation.Objects;
|
||||
using PrivaPub.Federation.Signing;
|
||||
@@ -9,13 +10,22 @@ using System.Threading.RateLimiting;
|
||||
|
||||
namespace PrivaPub.Infrastructure
|
||||
{
|
||||
public class RateLimitOptions
|
||||
{
|
||||
public int AccountsPerMinute { get; set; } = 10;//sign-ups, sign-ins and recoveries per client address
|
||||
public int InboxBurst { get; set; } = 300;//deliveries a sending origin may make at once
|
||||
public int InboxPerTenSeconds { get; set; } = 50;//and the rate it earns them back
|
||||
}
|
||||
|
||||
public static class RateLimiting
|
||||
{
|
||||
public const string Accounts = "accounts";
|
||||
public const string Inbox = "inbox";
|
||||
|
||||
public static IServiceCollection PrivaPubRateLimiting(this IServiceCollection service) =>
|
||||
service.AddRateLimiter(options =>
|
||||
static RateLimitOptions Limits(HttpContext context) => context.RequestServices.GetRequiredService<IOptions<RateLimitOptions>>().Value;
|
||||
|
||||
public static IServiceCollection PrivaPubRateLimiting(this IServiceCollection service, IConfiguration configuration) =>
|
||||
service.Configure<RateLimitOptions>(configuration.GetSection("RateLimits")).AddRateLimiter(options =>
|
||||
{
|
||||
options.RejectionStatusCode = StatusCodes.Status429TooManyRequests;
|
||||
options.OnRejected = (context, _) =>
|
||||
@@ -39,13 +49,13 @@ namespace PrivaPub.Infrastructure
|
||||
};
|
||||
options.AddPolicy(Accounts, context => RateLimitPartition.GetFixedWindowLimiter(
|
||||
context.Connection.RemoteIpAddress?.ToString() ?? "unknown",
|
||||
_ => new FixedWindowRateLimiterOptions { PermitLimit = 10, Window = TimeSpan.FromMinutes(1), QueueLimit = 0 }));
|
||||
_ => new FixedWindowRateLimiterOptions { PermitLimit = Limits(context).AccountsPerMinute, Window = TimeSpan.FromMinutes(1), QueueLimit = 0 }));
|
||||
options.AddPolicy(Inbox, context => RateLimitPartition.GetTokenBucketLimiter(
|
||||
SenderOrigin(context.Request) ?? "unsigned:" + context.Connection.RemoteIpAddress,
|
||||
_ => new TokenBucketRateLimiterOptions
|
||||
{
|
||||
TokenLimit = 300,
|
||||
TokensPerPeriod = 50,
|
||||
TokenLimit = Limits(context).InboxBurst,
|
||||
TokensPerPeriod = Limits(context).InboxPerTenSeconds,
|
||||
ReplenishmentPeriod = TimeSpan.FromSeconds(10),
|
||||
QueueLimit = 0
|
||||
}));
|
||||
|
||||
+1
-1
@@ -59,7 +59,7 @@ try
|
||||
.PrivaPubFederationConfiguration(builder.Configuration)
|
||||
.PrivaPubStatisticsConfiguration(builder.Configuration)
|
||||
.PrivaPubCORSConfiguration()
|
||||
.PrivaPubRateLimiting()
|
||||
.PrivaPubRateLimiting(builder.Configuration)
|
||||
.PrivaPubOAuth(builder.Environment)
|
||||
.AddScoped<PrivaPub.Api.Mastodon.Mappers.MastodonMapper>()
|
||||
.AddScoped<PrivaPub.Api.Mastodon.Mappers.AccountSearch>()
|
||||
|
||||
+82
-1
@@ -310,6 +310,26 @@ Firefish is dead (its site has answered 410 since February 2025).
|
||||
| Per-attachment `sensitive`; `isCat` and other actor extras; enforce `requireSignin…` and `makeNotes…Before` on our public pages | P2 | own `privapub.*` |
|
||||
| Quotes: when we send one, send every key (`quote`, `_misskey_quote`, `quoteUrl`, `quoteUri`, a FEP-e232 tag, the `RE:` fallback) | P2 | — |
|
||||
|
||||
**Pasture evidence (2026-10-03, Misskey 2026.10.0, `tools/pasture/scenarios/misskey.sh`):** 35 checks pass:
|
||||
- discovery and follows both ways;
|
||||
- posts, CW and the MFM `source` kept;
|
||||
- replies threading both ways;
|
||||
- 👍 and 🎉 reactions both ways, and their withdrawal;
|
||||
- a like counting as a reaction;
|
||||
- legacy quotes both ways (`_misskey_quote` in, `renoteId` out);
|
||||
- polls and votes both ways;
|
||||
- `specified` notes as DMs both ways;
|
||||
- images with alt text (`comment`) both ways;
|
||||
- deletes both ways, unfollow and block;
|
||||
- statistics.
|
||||
|
||||
A new Misskey 2026 starts with `federation: none`.
|
||||
|
||||
**Pasture evidence (2026-10-03, Sharkey 2025.4.7, `tools/pasture/scenarios/sharkey.sh`):** 40 checks pass, the whole
|
||||
Misskey scenario under Sharkey's name and then what only Sharkey does:
|
||||
- its edits arrive as edits, and ours reach it;
|
||||
- its quote carries the FEP-e232 `Link` tag and is understood.
|
||||
|
||||
### Pleroma 2.10.2 and Akkoma 3.20.1
|
||||
|
||||
**Emits**
|
||||
@@ -321,6 +341,7 @@ Firefish is dead (its site has answered 410 since February 2025).
|
||||
- **Edit history:** `formerRepresentations`, an OrderedCollection of earlier versions.
|
||||
- **Reactions:** `EmojiReact{content: ":name:", tag:[Emoji]}`, several per user; separate from Like.
|
||||
- **Polls:** `votersCount` (Pleroma 2.10.1, Akkoma 3.20). A vote is a `Note{name, inReplyTo, to:[], cc:[owner]}`.
|
||||
An open poll's end is in `closed`, and Akkoma sends no `endTime` (pasture, 3.20.1).
|
||||
- **Pleroma only:**
|
||||
- `ChatMessage`, sent only to actors with `capabilities.acceptsChatMessages`;
|
||||
- `Listen{Audio}`;
|
||||
@@ -333,6 +354,8 @@ Firefish is dead (its site has answered 410 since February 2025).
|
||||
- **Pleroma's inbox guard answers 400 for unknown activity types:** `Move`, `QuoteRequest` and `Bite` are not on its
|
||||
list (develop, 2026-09-30). Treat that 4xx as final.
|
||||
- **Signatures (Akkoma):** `host` must be signed and match; signatures up to 2 h old and up to 40 min in the future.
|
||||
- **Visibility is guessed from addresses:** a post is private only if an address in `to` contains `/followers` or its
|
||||
`cc` is not empty; otherwise it is direct. Our followers-only posts therefore name `/groupies` in `cc` too.
|
||||
- **Activity ids** must be at least 8 bytes.
|
||||
- **ObjectAgePolicy** (default in both) delists anything older than 7 days, so `published` must be accurate.
|
||||
- **Quotes:** Pleroma's InlineQuotePolicy rewrites incoming quotes into "RT: url" text. **Neither reads FEP-044f
|
||||
@@ -349,6 +372,35 @@ Firefish is dead (its site has answered 410 since February 2025).
|
||||
| `ChatMessage` in as a direct message (also needed for Lemmy, Mbin and PieFed); advertise `acceptsChatMessages` only once it is answered | P1 (in), P3 (out) | `visibility: direct`, Conversations |
|
||||
| `Listen`, `vcard:bday`, `backgroundUrl` | P3 | own |
|
||||
|
||||
**Pasture evidence (2026-10-03, Akkoma 3.20.1, `tools/pasture/scenarios/akkoma.sh`):** 44 checks pass, three runs in a
|
||||
row. Akkoma publishes no image, so `tools/pasture/images/akkoma` installs its OTP release, pinned by checksum. Covered:
|
||||
- discovery and follows both ways;
|
||||
- posts, CW and followers-only posts;
|
||||
- the `published` time kept;
|
||||
- replies both ways and their notification;
|
||||
- likes and boosts both ways with their undos;
|
||||
- `EmojiReact` both ways and its withdrawal;
|
||||
- DMs both ways and off public timelines;
|
||||
- polls and votes both ways;
|
||||
- quotes both ways (`quote_id` out of its API, `quoteUri` in ours);
|
||||
- images with alt text both ways;
|
||||
- edits with history, and deletes, both ways;
|
||||
- unfollow, block and unblock;
|
||||
- statistics.
|
||||
|
||||
It found two bugs, both fixed:
|
||||
- **Followers-only posts arrived as DMs.** Akkoma, like Pleroma, calls a post private only if an address in `to` contains
|
||||
`/followers` or its `cc` is not empty. Ours is `/groupies` and a post mentioning nobody had an empty `cc`. Followers-only
|
||||
posts now name the followers collection in `cc` as well, which tells nobody anything new.
|
||||
- **Open polls were shown as ended, and votes refused.** An open Akkoma poll carries its end in `closed`, with no
|
||||
`endTime`. A `closed` in the future is now read as the end.
|
||||
|
||||
Seen along the way:
|
||||
- Its Linkify never takes `@user@host.test` for a mention, whatever `validate_tld` says, so in the pasture Akkoma
|
||||
addresses us with Pleroma's `to[]`. Real top-level domains are unaffected.
|
||||
- It records our `Block` (`user_relationships`) but never reports a remote blocker as `blocked_by`.
|
||||
- Its streamer crashes rendering a new DM conversation (`ConversationView`, a nil `last_status`); delivery is unaffected.
|
||||
|
||||
### Lemmy: 0.19.20 live (lemmy.ml); 1.0.0-beta.2 (2026-09-25) in beta since May
|
||||
|
||||
join-lemmy.org's federation page is out of date. Current Lemmy neither sends nor reads `stickied` or `commentsEnabled`
|
||||
@@ -412,7 +464,7 @@ on a Page: pins live in `featured`, locks in `Lock`.
|
||||
| Link posts: keep `Link.href`, the thumbnail `image` and alt text; build the card (Lemmy sends no title or description for the link) | P1 | `Status.card` |
|
||||
| `ChatMessage` in and out (out only to Lemmy < 1.0 and Mbin; `Note` to everyone else) | P1 | `visibility: direct` |
|
||||
| Outbound shape for Lemmy: both `to` and `cc`; the community in `to`; Public in the object, Create and Announce; votes and comments sent to the community inbox | P1 | — |
|
||||
| Communities we host: announce to every follower instance including the author's; pick `Announce(object)` per peer by NodeInfo (as PieFed does) | P1 | — |
|
||||
| Communities we host: pick `Announce(object)` per peer by NodeInfo (as PieFed does). Announcing to every follower instance, the author's included, is done and needed (pasture evidence below) | P1 | — |
|
||||
| Flags from a `Service`-typed reporter actor with `to: [community]`; the reporter stays anonymous | P2 | — |
|
||||
| `Warn` → `moderation_warning` notification; `Resolve{Flag}` | P2 | AccountWarning |
|
||||
| Remote communities: `description`, `language[]`, private (`locked`), `discoverable`; post tags | P2 | `Account.locked`, own `privapub.flairs[]` |
|
||||
@@ -420,6 +472,35 @@ on a Page: pins live in `featured`, locks in `Lock`.
|
||||
| `Feed` actors | P2 | group-like account |
|
||||
| Read 1.0 `context`, grouped by root post; cross-post detection by URL | P3 | — |
|
||||
|
||||
**Pasture evidence (2026-10-03, Lemmy 1.0.0-beta.2, `tools/pasture/scenarios/lemmy.sh`):** 20 checks pass and 3 are
|
||||
expected failures:
|
||||
- communities both ways: Lemmy follows ours and alice follows Lemmy's, each Accept arriving;
|
||||
- a Lemmy thread in our community arrives with its title, and our titled community post reaches Lemmy;
|
||||
- the Lemmy community's Announce brings its thread to alice's home;
|
||||
- alice's post mentioning a Lemmy community lands in it, titled from its first line (Lemmy repeats that line in the
|
||||
body);
|
||||
- comments both ways, and alice's like counted as an upvote;
|
||||
- private messages both ways: 1.0 takes our single-recipient direct `Note`, and sends its own as `Note`s;
|
||||
- statistics.
|
||||
|
||||
What it showed:
|
||||
- **1.0 keeps its user's thread in a remote community `federation_pending` until the community announces it back**, and
|
||||
clears the flag *before* answering that echo 400 (`Object is not remote`). Without the echo the thread stays pending,
|
||||
so `GroupDistributor` sends the `Announce{Create}` to the author's own server on purpose.
|
||||
- **Every bare `Announce{object}` is answered 400** (`Failed to parse object`: Lemmy dereferences it expecting an
|
||||
activity), as Lemmy answers the compatibility `Announce(Page)` it sends itself. Both 400s show up as dead deliveries
|
||||
in the statistics.
|
||||
- **Votes travel only to the community**, which relays them as `Announce{Like}` and `Announce{Dislike}`. They are
|
||||
dropped as `unsupported` until P7 (expected failures), as is a moderator's removal.
|
||||
- Lemmy logs no refused activity at `warn`; the reason is in the 400's body, which our delivery does not keep. The
|
||||
scenario's API notes: `sort` values are lowercase (`new`), private messages and mentions are in
|
||||
`account/notification/list`, and `resolve_object` takes both `!community@host` and `@user@host`.
|
||||
- **1.0 sends nothing it queued for a server before it started that server's send worker.** A worker starts, up to a
|
||||
minute after the server is first seen, at the newest activity and skips everything older. On a clean pasture its
|
||||
first Follow of our community was lost, so the scenario waits for the worker (`federation_queue_state`). On the
|
||||
public network the same applies to the first thing a Lemmy sends to a PrivaPub it has just discovered.
|
||||
- 0.19 cannot join the pasture: its rustls trusts only its bundled roots, never Caddy's CA.
|
||||
|
||||
### PieFed 1.7.17 and Mbin 1.10.1
|
||||
|
||||
- **PieFed sends Lemmy's set plus:**
|
||||
|
||||
+16
-4
@@ -17,7 +17,19 @@ Written 2026-10-01 from the original 2023 code, the decePubClient UI, a federati
|
||||
- daily rollups, every touched server described and located, the admin statistics API, the opt-in crawler and `/stargazing`;
|
||||
- the pasture as plugins: GoToSocial 37/37, Mastodon 49 plus 2 expected failures.
|
||||
|
||||
Still open: T10 (GoToSocial gaps), T12 Misskey/Sharkey, T13 Akkoma, T14 Lemmy, and installing the geolocation timer on Max.
|
||||
T10 and T12–T14: v1.17.1, 2026-10-03. The pasture runs all six peers in one pass:
|
||||
- GoToSocial 54 plus 1 expected failure;
|
||||
- Mastodon 49 plus 2;
|
||||
- Misskey 35, Sharkey 40, Akkoma 44;
|
||||
- Lemmy 20 plus 3.
|
||||
|
||||
The expected failures are circle posts on GoToSocial and Mastodon, inbound Block, and Lemmy's relayed votes and
|
||||
removals (P7). It found three bugs, all fixed:
|
||||
- quick GoToSocial edits were lost;
|
||||
- followers-only posts arrived as DMs on Pleroma and Akkoma;
|
||||
- Akkoma's open polls showed as ended and refused votes.
|
||||
|
||||
Still open: installing the geolocation timer on Max (`deploy/max/setup.sh`, as root).
|
||||
- [ ] P7 Threads, communities, moderation, the social graph
|
||||
- [ ] P8 Signatures, discovery, the long tail
|
||||
|
||||
@@ -551,9 +563,9 @@ statistics gain value with every day recorded.
|
||||
| T1–T4 | Tests stop sharing state they don't own. CI runs every test against a throwaway mongod. The whole server runs under test (`WebApplicationFactory`). Nothing answers 500. | v1.15.1 |
|
||||
| M1–M6 | The interaction ledger: inbox answers, handler verdicts, delivery attempts, outbound requests, served and client traffic. Provenance fixes (fetched records, stored extensions, group-wrapped Update/Delete). | v1.16.0 |
|
||||
| T5–T8 | Coverage sweep over HTTP: OAuth, `/clientapi`, the Mastodon API, federation GETs, inbox gaps, jobs, migrations, pages. | v1.17.0 |
|
||||
| M7–M10 | Daily rollups (`InstanceDay`, `ServerDay`). Every touched server described weekly (NodeInfo usage, instance API, snapshots). Geolocation (DB-IP Lite city and ASN). Admin statistics API under `/clientapi/admin/statistics`. | v1.18.0 |
|
||||
| T9–T14 | The pasture as plugins. GoToSocial gaps, then Mastodon, Misskey/Sharkey, Akkoma and Lemmy 1.0, each run also checking that peer's statistics. | v1.18.x |
|
||||
| M11 | The opt-in crawler (`PrivaPub-Stargazer`) and the `/stargazing` explainer. | v1.19.0 |
|
||||
| M7–M10 | Daily rollups (`InstanceDay`, `ServerDay`). Every touched server described weekly (NodeInfo usage, instance API, snapshots). Geolocation (DB-IP Lite city and ASN). Admin statistics API under `/clientapi/admin/statistics`. | v1.17.0 (planned v1.18.0) |
|
||||
| T9–T14 | The pasture as plugins. GoToSocial gaps, then Mastodon, Misskey/Sharkey, Akkoma and Lemmy 1.0, each run also checking that peer's statistics. | v1.17.0, v1.17.1 |
|
||||
| M11 | The opt-in crawler (`PrivaPub-Stargazer`) and the `/stargazing` explainer. | v1.17.0 (planned v1.19.0) |
|
||||
|
||||
Later, and not part of these steps: the public `/stargazing` statistics. It is anonymous, cached and rate-limited, and
|
||||
covers per-server software, self-published counts, location as projected by the rule above, availability buckets and
|
||||
|
||||
@@ -17,3 +17,23 @@ mastodon.test {
|
||||
tls internal
|
||||
reverse_proxy pasture-mastodon:3000
|
||||
}
|
||||
|
||||
misskey.test {
|
||||
tls internal
|
||||
reverse_proxy pasture-misskey:3000
|
||||
}
|
||||
|
||||
sharkey.test {
|
||||
tls internal
|
||||
reverse_proxy pasture-sharkey:3000
|
||||
}
|
||||
|
||||
lemmy.test {
|
||||
tls internal
|
||||
reverse_proxy pasture-lemmy:8536
|
||||
}
|
||||
|
||||
akkoma.test {
|
||||
tls internal
|
||||
reverse_proxy pasture-akkoma:4000
|
||||
}
|
||||
@@ -25,6 +25,7 @@
|
||||
"AcceptAnyCertificate": true
|
||||
},
|
||||
"Media": { "Root": "/tmp/privapub-media" },
|
||||
"RateLimits": { "AccountsPerMinute": 1000 },
|
||||
"Kestrel": { "Endpoints": { "Http": { "Url": "http://0.0.0.0:80", "Protocols": "Http1AndHttp2" } } },
|
||||
"Serilog": {
|
||||
"MinimumLevel": { "Default": "Information", "Override": { "Microsoft": "Warning", "System": "Warning" } },
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
# Akkoma publishes no image, so this is its OTP release on the Ubuntu it is built for. The "stable" zip moves; the
|
||||
# checksum pins 3.20.1 and fails the build when stable has moved on.
|
||||
FROM docker.io/library/ubuntu:22.04
|
||||
ARG AKKOMA_ZIP=https://akkoma-updates.s3-website.fr-par.scw.cloud/stable/akkoma-amd64.zip
|
||||
ARG AKKOMA_SHA256=a06d1077dc43357923ffbebbee509f1faa2d66ae6078828ad35f9ad34ec6f96c
|
||||
RUN apt-get update \
|
||||
&& DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends \
|
||||
ca-certificates curl unzip libncurses5 libmagic1 libmagic-mgc file postgresql-client \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
RUN curl -fsSL "$AKKOMA_ZIP" -o /tmp/akkoma.zip \
|
||||
&& echo "$AKKOMA_SHA256 /tmp/akkoma.zip" | sha256sum -c - \
|
||||
&& unzip -q /tmp/akkoma.zip -d /tmp && mv /tmp/release /opt/akkoma && rm /tmp/akkoma.zip \
|
||||
&& mkdir -p /var/lib/akkoma/uploads /var/lib/akkoma/static /etc/akkoma
|
||||
ENV AKKOMA_CONFIG_PATH=/etc/akkoma/config.exs PLEROMA_CONFIG_PATH=/etc/akkoma/config.exs LANG=C.UTF-8 ELIXIR_ERL_OPTIONS=+fnu
|
||||
COPY entrypoint.sh /entrypoint.sh
|
||||
ENTRYPOINT ["/entrypoint.sh"]
|
||||
Executable
+19
@@ -0,0 +1,19 @@
|
||||
#!/bin/sh
|
||||
# Trusts the pasture's CA, writes the config on first start, migrates, and runs Akkoma in the foreground.
|
||||
set -e
|
||||
cp /pasture/ca/root.crt /usr/local/share/ca-certificates/pasture.crt && update-ca-certificates >/dev/null
|
||||
# Mint and hackney read the bundles shipped in the release (CAStore, certifi), never the system's
|
||||
for bundle in /opt/akkoma/lib/castore-*/priv/cacerts.pem /opt/akkoma/lib/certifi-*/priv/cacerts.pem; do
|
||||
grep -q "pasture" "$bundle" || { echo "# pasture"; cat /pasture/ca/root.crt; } >> "$bundle"
|
||||
done
|
||||
if [ ! -f /etc/akkoma/config.exs ]; then
|
||||
# pleroma_ctl passes its arguments on unquoted, so no value may contain a space
|
||||
env -u AKKOMA_CONFIG_PATH -u PLEROMA_CONFIG_PATH /opt/akkoma/bin/pleroma_ctl instance gen --force --output /etc/akkoma/config.exs --output-psql /tmp/setup.psql \
|
||||
--domain akkoma.test --instance-name PastureAkkoma --admin-email admin@akkoma.test --notify-email admin@akkoma.test \
|
||||
--dbhost postgres --dbname akkoma --dbuser pasture --dbpass pasture --rum N --indexable N --db-configurable N \
|
||||
--uploads-dir /var/lib/akkoma/uploads --static-dir /var/lib/akkoma/static --listen-ip 0.0.0.0 --listen-port 4000 \
|
||||
--media-url https://akkoma.test/media --strip-uploads-metadata N --read-uploads-description Y --anonymize-uploads N \
|
||||
</dev/null
|
||||
fi
|
||||
/opt/akkoma/bin/pleroma_ctl migrate
|
||||
exec /opt/akkoma/bin/pleroma start
|
||||
@@ -1,7 +1,7 @@
|
||||
#!/usr/bin/env bash
|
||||
# Drives PrivaPub and each peer (started by run.sh) through each other's federation, as their users would, then checks
|
||||
# that PrivaPub's statistics saw it all without naming anyone.
|
||||
# usage: tools/pasture/interop.sh [peer...] peers: gts (default)
|
||||
# usage: tools/pasture/interop.sh [peer...] peers: gts (default), mastodon, misskey, sharkey, akkoma, lemmy, crawler
|
||||
set -uo pipefail
|
||||
here="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
. "$here/lib/interop.sh"
|
||||
|
||||
@@ -14,6 +14,14 @@ site() { curl -k --resolve "$1:6443:127.0.0.1" "${@:2}"; }
|
||||
# fetches one of PrivaPub's own https URIs (ids, scribbles) from the workstation, through Caddy
|
||||
pfetch() { curl -sk --connect-to privapub.test:443:127.0.0.1:6443 "$@"; }
|
||||
|
||||
# make_png <path>: an 8x8 red PNG, for uploads
|
||||
make_png() { python3 -c "
|
||||
import struct,zlib
|
||||
w=h=8
|
||||
raw=b''.join(b'\x00'+bytes([200,60,60])*w for _ in range(h))
|
||||
png=b'\x89PNG\r\n\x1a\n'+b''.join(struct.pack('>I',len(c))+t+c+struct.pack('>I',zlib.crc32(t+c)&0xffffffff) for t,c in [(b'IHDR',struct.pack('>IIBBBBB',w,h,8,2,0,0,0)),(b'IDAT',zlib.compress(raw)),(b'IEND',b'')])
|
||||
open('$1','wb').write(png)"; }
|
||||
|
||||
ROOT_USER=pastureroot; ROOT_PASS='Pasture-Pass-1!'
|
||||
privapub_root() {
|
||||
local root
|
||||
|
||||
@@ -2,6 +2,8 @@
|
||||
here="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"; repo="$(cd "$here/../.." && pwd)"
|
||||
net=privapub-pasture; publish="$here/.publish"; ca="$here/.ca"
|
||||
|
||||
site() { curl -k --resolve "$1:6443:127.0.0.1" "${@:2}"; }
|
||||
|
||||
wait_http() { # url tries
|
||||
for _ in $(seq 1 "${2:-60}"); do curl -fsk -o /dev/null "$1" && return 0; sleep 2; done
|
||||
echo "timed out waiting for $1" >&2; return 1
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
# Akkoma 3.20.1: our own image (images/akkoma: the OTP release, pinned by checksum) on the shared Postgres. Its config
|
||||
# is generated on first start; the scenario's user is made with pleroma_ctl and signs in with the password grant.
|
||||
AKKOMA_IMAGE=${AKKOMA_IMAGE:-localhost/pasture-akkoma:3.20.1}
|
||||
. "$here/peers/shared.sh"
|
||||
|
||||
akkoma_up() {
|
||||
shared_postgres_up
|
||||
podman image exists "$AKKOMA_IMAGE" || podman build -q -t "$AKKOMA_IMAGE" "$here/images/akkoma" >/dev/null
|
||||
podman exec pasture-postgres sh -c "psql -U pasture -tc \"select 1 from pg_database where datname='akkoma'\" | grep -q 1 || createdb -U pasture akkoma"
|
||||
podman exec pasture-postgres psql -U pasture -d akkoma -qc 'create extension if not exists citext; create extension if not exists pg_trgm; create extension if not exists "uuid-ossp";' >/dev/null
|
||||
mkdir -p "$here/.state/akkoma"
|
||||
podman run -d --replace --name pasture-akkoma --network $net \
|
||||
-v "$here/.state/akkoma:/etc/akkoma:z" -v "$ca:/pasture/ca:z,ro" "$AKKOMA_IMAGE" >/dev/null
|
||||
for _ in $(seq 1 120); do
|
||||
site akkoma.test -s -o /dev/null -w '%{http_code}' https://akkoma.test:6443/api/v1/instance 2>/dev/null | grep -q 200 && break
|
||||
sleep 3
|
||||
done
|
||||
podman exec pasture-akkoma /opt/akkoma/bin/pleroma_ctl user new akuser akuser@akkoma.test --password Akkoma-Pasture-Pass-1 \
|
||||
--name akuser --admin --assume-yes >/dev/null 2>&1 || true
|
||||
local app cid cs
|
||||
app=$(site akkoma.test -s -X POST https://akkoma.test:6443/api/v1/apps -d 'client_name=pasture&redirect_uris=urn:ietf:wg:oauth:2.0:oob&scopes=read write follow push admin')
|
||||
cid=$(echo "$app" | python3 -c "import sys,json; print(json.load(sys.stdin)['client_id'])" 2>/dev/null)
|
||||
cs=$(echo "$app" | python3 -c "import sys,json; print(json.load(sys.stdin)['client_secret'])" 2>/dev/null)
|
||||
site akkoma.test -s -X POST https://akkoma.test:6443/oauth/token --data-urlencode grant_type=password --data-urlencode username=akuser \
|
||||
--data-urlencode password=Akkoma-Pasture-Pass-1 --data-urlencode "client_id=$cid" --data-urlencode "client_secret=$cs" \
|
||||
--data-urlencode 'scope=read write follow push admin' \
|
||||
| python3 -c "import sys,json; print(json.load(sys.stdin)['access_token'])" > "$here/.state/akkoma.token" 2>/dev/null || true
|
||||
echo "akkoma: https://akkoma.test:6443"
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
# Lemmy 1.0 (beta): the backend alone (no UI, no pict-rs) on the shared Postgres. 0.19's rustls ignores every CA but its
|
||||
# bundled ones, so only 1.0 can trust Caddy's; DANGER_FEDERATION_ALLOW_LOCAL_IP lets it reach the pasture's addresses.
|
||||
LEMMY_IMAGE=${LEMMY_IMAGE:-docker.io/dessalines/lemmy:1.0.0-beta.2}
|
||||
. "$here/peers/shared.sh"
|
||||
|
||||
lemmy_up() {
|
||||
shared_postgres_up
|
||||
podman exec pasture-postgres sh -c "psql -U pasture -tc \"select 1 from pg_database where datname='lemmy'\" | grep -q 1 || createdb -U pasture lemmy"
|
||||
mkdir -p "$here/.state/lemmy"
|
||||
cat > "$here/.state/lemmy/config.hjson" <<HJSON
|
||||
{
|
||||
database: { connection: "postgres://pasture:pasture@postgres:5432/lemmy" }
|
||||
hostname: "lemmy.test"
|
||||
bind: "0.0.0.0"
|
||||
port: 8536
|
||||
tls_enabled: true
|
||||
setup: {
|
||||
admin_username: "lemmyuser"
|
||||
admin_password: "Lemmy-Pasture-Pass-1"
|
||||
site_name: "Pasture Lemmy"
|
||||
admin_email: "lemmyuser@lemmy.test"
|
||||
}
|
||||
}
|
||||
HJSON
|
||||
podman run -d --replace --name pasture-lemmy --network $net -e LEMMY_CONFIG_LOCATION=/config/config.hjson \
|
||||
-e DANGER_FEDERATION_ALLOW_LOCAL_IP=1 -e SSL_CERT_FILE=/pasture/ca/bundle.pem -e RUST_LOG="${LEMMY_LOG:-warn}" \
|
||||
-v "$here/.state/lemmy:/config:z,ro" -v "$ca:/pasture/ca:z,ro" $LEMMY_IMAGE >/dev/null
|
||||
for _ in $(seq 1 90); do
|
||||
site lemmy.test -s -o /dev/null -w '%{http_code}' https://lemmy.test:6443/api/v4/site 2>/dev/null | grep -q 200 && break
|
||||
sleep 2
|
||||
done
|
||||
site lemmy.test -s -X POST https://lemmy.test:6443/api/v4/account/auth/login -H 'Content-Type: application/json' \
|
||||
-d '{"username_or_email":"lemmyuser","password":"Lemmy-Pasture-Pass-1"}' \
|
||||
| python3 -c "import sys,json; print(json.load(sys.stdin)['jwt'])" > "$here/.state/lemmy.token" 2>/dev/null || true
|
||||
echo "lemmy: https://lemmy.test:6443"
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
# Misskey: one container on the shared Postgres (database misskey) and Redis (db 2). The first account, made with the
|
||||
# setup password, is the scenario's user; its token is kept in .state/misskey.token.
|
||||
MISSKEY_IMAGE=${MISSKEY_IMAGE:-docker.io/misskey/misskey:2026.10.0}
|
||||
MISSKEY_NAME=${MISSKEY_NAME:-misskey}
|
||||
. "$here/peers/shared.sh"
|
||||
|
||||
misskey_up() {
|
||||
shared_postgres_up
|
||||
shared_redis_up
|
||||
podman exec pasture-postgres sh -c "psql -U pasture -tc \"select 1 from pg_database where datname='$MISSKEY_NAME'\" | grep -q 1 || createdb -U pasture $MISSKEY_NAME"
|
||||
mkdir -p "$here/.state/$MISSKEY_NAME"
|
||||
cat > "$here/.state/$MISSKEY_NAME/default.yml" <<YML
|
||||
url: https://$MISSKEY_NAME.test/
|
||||
port: 3000
|
||||
db:
|
||||
host: postgres
|
||||
port: 5432
|
||||
db: $MISSKEY_NAME
|
||||
user: pasture
|
||||
pass: pasture
|
||||
dbReplications: false
|
||||
redis:
|
||||
host: redis
|
||||
port: 6379
|
||||
db: ${MISSKEY_REDIS_DB:-2}
|
||||
fulltextSearch:
|
||||
provider: sqlLike
|
||||
id: 'aidx'
|
||||
setupPassword: pasture-setup
|
||||
allowedPrivateNetworks:
|
||||
- '10.0.0.0/8'
|
||||
- '172.16.0.0/12'
|
||||
- '192.168.0.0/16'
|
||||
YML
|
||||
podman run -d --replace --name pasture-$MISSKEY_NAME --network $net -e NODE_EXTRA_CA_CERTS=/pasture/ca/root.crt \
|
||||
-v "$here/.state/$MISSKEY_NAME:${MISSKEY_HOME:-/misskey}/.config:z,ro" -v "$ca:/pasture/ca:z,ro" $MISSKEY_IMAGE >/dev/null
|
||||
for _ in $(seq 1 120); do
|
||||
site "$MISSKEY_NAME.test" -s -o /dev/null -w '%{http_code}' -X POST "https://$MISSKEY_NAME.test:6443/api/meta" -H 'Content-Type: application/json' -d '{}' 2>/dev/null | grep -q 200 && break
|
||||
sleep 3
|
||||
done
|
||||
site "$MISSKEY_NAME.test" -s -X POST "https://$MISSKEY_NAME.test:6443/api/admin/accounts/create" -H 'Content-Type: application/json' \
|
||||
-d '{"username":"mkuser","password":"Misskey-Pasture-Pass-1","setupPassword":"pasture-setup"}' \
|
||||
| python3 -c "import sys,json; print(json.load(sys.stdin)['token'])" > "$here/.state/$MISSKEY_NAME.token" 2>/dev/null || true
|
||||
# a new Misskey federates with nobody (federation: none) until its admin says otherwise
|
||||
site "$MISSKEY_NAME.test" -s -o /dev/null -X POST "https://$MISSKEY_NAME.test:6443/api/admin/update-meta" -H 'Content-Type: application/json' \
|
||||
-d "{\"i\":\"$(cat "$here/.state/$MISSKEY_NAME.token")\",\"federation\":\"all\"}"
|
||||
echo "$MISSKEY_NAME: https://$MISSKEY_NAME.test:6443"
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
# Sharkey: the Misskey peer under its own name, image, database, Redis db and home. Its scenario is Misskey's plus what
|
||||
# only Sharkey sends.
|
||||
. "$here/peers/misskey.sh"
|
||||
|
||||
sharkey_up() {
|
||||
MISSKEY_NAME=sharkey MISSKEY_IMAGE=${SHARKEY_IMAGE:-registry.activitypub.software/transfem-org/sharkey:2025.4.7} \
|
||||
MISSKEY_REDIS_DB=3 MISSKEY_HOME=/sharkey misskey_up
|
||||
}
|
||||
@@ -2,7 +2,7 @@
|
||||
# A private test fediverse on one podman network: PrivaPub (privapub.test) and the peers asked for, behind one Caddy
|
||||
# whose internal CA every side is told to accept (its root is copied to .ca/root.crt). From the workstation: PrivaPub's
|
||||
# API at http://127.0.0.1:6971, every site at https://<name>.test:6443 (curl --resolve <name>.test:6443:127.0.0.1 -k).
|
||||
# usage: tools/pasture/run.sh up [peer...] | down | logs <name> | ps peers: gts (default)
|
||||
# usage: tools/pasture/run.sh up [peer...] | down | logs <name> | ps peers: gts (default), mastodon, misskey, sharkey, akkoma, lemmy
|
||||
set -euo pipefail
|
||||
. "$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/lib/pasture.sh"
|
||||
|
||||
|
||||
@@ -0,0 +1,142 @@
|
||||
# Akkoma 3.20: discovery, follows, posts, CW, replies both ways, likes and boosts with their undos, emoji reactions
|
||||
# both ways, DMs, polls, quotes both ways, edits with their history, deletes, media with alt text, the published time
|
||||
# kept, blocks, statistics. Akkoma speaks the Mastodon API, with reactions under /api/v1/pleroma.
|
||||
AK=https://akkoma.test:6443
|
||||
acurl() { curl -sk --resolve akkoma.test:6443:127.0.0.1 "$@"; }
|
||||
KT=$(cat "$here/.state/akkoma.token" 2>/dev/null)
|
||||
KH="Authorization: Bearer $KT"
|
||||
# what Akkoma holds of an account, newest first; and the one status whose uri is given
|
||||
a_statuses() { acurl -H "$KH" "$AK/api/v1/accounts/$1/statuses?limit=40"; }
|
||||
a_status_by_uri() { a_statuses "$1" | j "print(json.dumps(next((s for s in d if s['uri']=='$2'), None)))"; }
|
||||
# a PrivaPub status in alice_akkoma's home whose text has the given words
|
||||
p_home_id() { curl -s -H "$PH" "$P/api/v1/timelines/home?limit=40" | j "print(next((s['id'] for s in d if '$1' in s['content']), ''))"; }
|
||||
|
||||
echo "akkoma"
|
||||
[ -n "$KT" ] && ok "Akkoma token for akuser" || { ko "Akkoma token"; return 1; }
|
||||
PT=$(privapub_token alice_akkoma)
|
||||
PH="Authorization: Bearer $PT"
|
||||
[ -n "$PT" ] && ok "PrivaPub token for alice_akkoma" || { ko "PrivaPub token for alice_akkoma"; return 1; }
|
||||
alice_self=$(curl -s -H "$PH" "$P/api/v1/accounts/verify_credentials" | j "print(d['id'])")
|
||||
|
||||
echo " discovery"
|
||||
alice_on_a=$(acurl -H "$KH" "$AK/api/v2/search?q=@alice_akkoma@privapub.test&resolve=true&type=accounts" | j "print(d['accounts'][0]['id'])")
|
||||
[ -n "$alice_on_a" ] && ok "Akkoma resolves @alice_akkoma@privapub.test" || ko "Akkoma cannot resolve alice_akkoma"
|
||||
ak_on_p=$(curl -s -H "$PH" "$P/api/v2/search?q=akuser@akkoma.test&resolve=true&type=accounts" | j "print(d['accounts'][0]['id'])")
|
||||
[ -n "$ak_on_p" ] && ok "PrivaPub resolves @akuser@akkoma.test" || ko "PrivaPub cannot resolve akuser"
|
||||
|
||||
echo " follows"
|
||||
acurl -o /dev/null -X POST -H "$KH" "$AK/api/v1/accounts/$alice_on_a/follow"
|
||||
until_true 30 '[ "$(acurl -H "$KH" "$AK/api/v1/accounts/relationships?id[]=$alice_on_a" | j "print(d[0][\"following\"])")" = "True" ]' && ok "akuser follows alice_akkoma (Accept arrived)" || ko "Akkoma's follow not accepted"
|
||||
curl -s -o /dev/null -X POST -H "$PH" "$P/api/v1/accounts/$ak_on_p/follow"
|
||||
until_true 30 '[ "$(curl -s -H "$PH" "$P/api/v1/accounts/relationships?id[]=$ak_on_p" | j "print(d[0][\"following\"])")" = "True" ]' && ok "alice_akkoma follows akuser (Accept arrived)" || ko "PrivaPub's follow not accepted"
|
||||
|
||||
echo " posts"
|
||||
p_post=$(curl -s -X POST -H "$PH" $P/api/v1/statuses -d 'status=Hello Akkoma from PrivaPub&visibility=public')
|
||||
p_post_id=$(echo "$p_post" | j "print(d['id'])"); p_post_uri=$(echo "$p_post" | j "print(d['uri'])")
|
||||
until_true 30 '[ "$(a_status_by_uri "$alice_on_a" "$p_post_uri")" != "null" ]' && ok "alice_akkoma's post reaches Akkoma" || ko "post missing on Akkoma"
|
||||
p_post_on_a=$(a_status_by_uri "$alice_on_a" "$p_post_uri" | j "print(d['id'])")
|
||||
a_post=$(acurl -X POST -H "$KH" "$AK/api/v1/statuses" -d 'status=Hello PrivaPub from Akkoma&visibility=public')
|
||||
a_post_id=$(echo "$a_post" | j "print(d['id'])")
|
||||
until_true 30 '[ -n "$(p_home_id "Hello PrivaPub from Akkoma")" ]' && ok "akuser's post reaches alice_akkoma's home" || ko "Akkoma's post missing on PrivaPub"
|
||||
a_post_on_p=$(p_home_id "Hello PrivaPub from Akkoma")
|
||||
a_created=$(echo "$a_post" | j "print(d['created_at'][:19])")
|
||||
[ "$(curl -s -H "$PH" "$P/api/v1/statuses/$a_post_on_p" | j "print(d['created_at'][:19])")" = "$a_created" ] \
|
||||
&& ok "akuser's post keeps its published time" || ko "published time changed on PrivaPub"
|
||||
cw_uri=$(curl -s -X POST -H "$PH" $P/api/v1/statuses -d 'status=behind a warning&spoiler_text=spoilers&visibility=public' | j "print(d['uri'])")
|
||||
until_true 30 '[ "$(a_status_by_uri "$alice_on_a" "$cw_uri" | j "print(d and d[\"spoiler_text\"]==\"spoilers\" and d[\"sensitive\"])")" = "True" ]' && ok "a content warning survives to Akkoma" || ko "content warning lost on Akkoma"
|
||||
fo_uri=$(curl -s -X POST -H "$PH" $P/api/v1/statuses -d 'status=only for followers&visibility=private' | j "print(d['uri'])")
|
||||
until_true 30 '[ "$(a_status_by_uri "$alice_on_a" "$fo_uri" | j "print(d and d[\"visibility\"])")" = "private" ]' && ok "a followers-only post reaches Akkoma as private" || ko "followers-only post missing or widened on Akkoma"
|
||||
|
||||
echo " replies"
|
||||
acurl -o /dev/null -X POST -H "$KH" "$AK/api/v1/statuses" -d "status=@alice_akkoma@privapub.test replying from Akkoma&in_reply_to_id=$p_post_on_a&visibility=public"
|
||||
until_true 30 'curl -s -H "$PH" "$P/api/v1/notifications" | j "print(any(n[\"type\"]==\"mention\" for n in d))" | grep -q True' && ok "akuser's reply notifies alice_akkoma" || ko "reply did not notify"
|
||||
until_true 15 '[ "$(curl -s -H "$PH" "$P/api/v1/statuses/$p_post_id/context" | j "print(len(d[\"descendants\"]))")" -ge 1 ]' && ok "the reply threads under alice_akkoma's post" || ko "reply not threaded on PrivaPub"
|
||||
curl -s -o /dev/null -X POST -H "$PH" $P/api/v1/statuses -d "status=@akuser@akkoma.test replying from PrivaPub&in_reply_to_id=$a_post_on_p&visibility=public"
|
||||
until_true 30 '[ "$(acurl -H "$KH" "$AK/api/v1/statuses/$a_post_id/context" | j "print(len(d[\"descendants\"]))")" -ge 1 ]' && ok "alice_akkoma's reply threads under akuser's post" || ko "outbound reply not threaded on Akkoma"
|
||||
|
||||
echo " likes, boosts and reactions"
|
||||
curl -s -o /dev/null -X POST -H "$PH" "$P/api/v1/statuses/$a_post_on_p/favourite"
|
||||
curl -s -o /dev/null -X POST -H "$PH" "$P/api/v1/statuses/$a_post_on_p/reblog"
|
||||
until_true 30 '[ "$(acurl -H "$KH" "$AK/api/v1/statuses/$a_post_id" | j "print(d[\"favourites_count\"], d[\"reblogs_count\"])")" = "1 1" ]' && ok "alice_akkoma's like and boost count on Akkoma" || ko "like or boost not counted on Akkoma"
|
||||
curl -s -o /dev/null -X POST -H "$PH" "$P/api/v1/statuses/$a_post_on_p/unfavourite"
|
||||
curl -s -o /dev/null -X POST -H "$PH" "$P/api/v1/statuses/$a_post_on_p/unreblog"
|
||||
until_true 30 '[ "$(acurl -H "$KH" "$AK/api/v1/statuses/$a_post_id" | j "print(d[\"favourites_count\"], d[\"reblogs_count\"])")" = "0 0" ]' && ok "alice_akkoma's unlike and unboost reach Akkoma" || ko "undo of like or boost not applied on Akkoma"
|
||||
acurl -o /dev/null -X POST -H "$KH" "$AK/api/v1/statuses/$p_post_on_a/favourite"
|
||||
acurl -o /dev/null -X POST -H "$KH" "$AK/api/v1/statuses/$p_post_on_a/reblog"
|
||||
until_true 30 '[ "$(curl -s -H "$PH" "$P/api/v1/statuses/$p_post_id" | j "print(d[\"favourites_count\"], d[\"reblogs_count\"])")" = "1 1" ]' && ok "akuser's like and boost count on PrivaPub" || ko "like or boost not counted on PrivaPub"
|
||||
acurl -o /dev/null -X POST -H "$KH" "$AK/api/v1/statuses/$p_post_on_a/unfavourite"
|
||||
acurl -o /dev/null -X POST -H "$KH" "$AK/api/v1/statuses/$p_post_on_a/unreblog"
|
||||
until_true 30 '[ "$(curl -s -H "$PH" "$P/api/v1/statuses/$p_post_id" | j "print(d[\"favourites_count\"], d[\"reblogs_count\"])")" = "0 0" ]' && ok "akuser's unlike and unboost reach PrivaPub" || ko "undo of like or boost not applied on PrivaPub"
|
||||
acurl -o /dev/null -X PUT -H "$KH" "$AK/api/v1/pleroma/statuses/$p_post_on_a/reactions/%F0%9F%8E%89"
|
||||
until_true 30 '[ "$(curl -s -H "$PH" "$P/api/v1/statuses/$p_post_id" | j "print(any(r[\"name\"]==\"🎉\" and r[\"count\"]==1 for r in d.get(\"emoji_reactions\") or []))")" = "True" ]' \
|
||||
&& ok "akuser's 🎉 (EmojiReact) arrives as a reaction" || ko "Akkoma's reaction missing on PrivaPub"
|
||||
acurl -o /dev/null -X DELETE -H "$KH" "$AK/api/v1/pleroma/statuses/$p_post_on_a/reactions/%F0%9F%8E%89"
|
||||
until_true 30 '[ "$(curl -s -H "$PH" "$P/api/v1/statuses/$p_post_id" | j "print(len(d.get(\"emoji_reactions\") or []))")" = "0" ]' && ok "akuser's withdrawn reaction is gone" || ko "withdrawn reaction still on PrivaPub"
|
||||
curl -s -o /dev/null -X PUT -H "$PH" "$P/api/v1/pleroma/statuses/$a_post_on_p/reactions/%F0%9F%91%8D"
|
||||
until_true 30 '[ "$(acurl -H "$KH" "$AK/api/v1/statuses/$a_post_id" | j "print(any(r[\"name\"]==\"👍\" and r[\"count\"]==1 for r in (d.get(\"pleroma\") or {}).get(\"emoji_reactions\") or d.get(\"emoji_reactions\") or []))")" = "True" ]' \
|
||||
&& ok "alice_akkoma's 👍 is a reaction on Akkoma" || ko "PrivaPub's reaction missing on Akkoma"
|
||||
curl -s -o /dev/null -X DELETE -H "$PH" "$P/api/v1/pleroma/statuses/$a_post_on_p/reactions/%F0%9F%91%8D"
|
||||
until_true 30 '[ "$(acurl -H "$KH" "$AK/api/v1/statuses/$a_post_id" | j "print(len((d.get(\"pleroma\") or {}).get(\"emoji_reactions\") or d.get(\"emoji_reactions\") or []))")" = "0" ]' && ok "alice_akkoma's withdrawn reaction is gone from Akkoma" || ko "withdrawn reaction still on Akkoma"
|
||||
|
||||
echo " direct messages"
|
||||
curl -s -o /dev/null -X POST -H "$PH" $P/api/v1/statuses -d 'status=@akuser@akkoma.test a secret for Akkoma&visibility=direct'
|
||||
until_true 30 'acurl -H "$KH" "$AK/api/v1/conversations" | grep -q "a secret for Akkoma"' && ok "alice_akkoma's DM reaches akuser" || ko "DM missing on Akkoma"
|
||||
# Akkoma's Linkify never takes @user@host.test for a mention, so its DM names alice with Pleroma's to[] instead
|
||||
acurl -o /dev/null -X POST -H "$KH" "$AK/api/v1/statuses" -d 'status=a secret for PrivaPub&visibility=direct&to[]=alice_akkoma@privapub.test'
|
||||
until_true 30 'curl -s -H "$PH" "$P/api/v1/conversations" | grep -q "a secret for PrivaPub"' && ok "akuser's DM reaches alice_akkoma" || ko "DM missing on PrivaPub"
|
||||
! curl -s "$P/api/v1/timelines/public" | grep -q "a secret for PrivaPub" && ok "the DM is not on PrivaPub's public timeline" || ko "DM leaked to the public timeline"
|
||||
|
||||
echo " polls"
|
||||
p_poll_uri=$(curl -s -X POST -H "$PH" $P/api/v1/statuses -d 'status=cats or dogs&visibility=public&poll[options][]=cats&poll[options][]=dogs&poll[expires_in]=3600' | j "print(d['uri'])")
|
||||
until_true 30 '[ "$(a_status_by_uri "$alice_on_a" "$p_poll_uri" | j "print(len(d[\"poll\"][\"options\"]))")" = "2" ]' && ok "alice_akkoma's poll reaches Akkoma as a poll" || ko "poll missing on Akkoma"
|
||||
p_poll_on_a=$(a_status_by_uri "$alice_on_a" "$p_poll_uri" | j "print(d['poll']['id'])")
|
||||
acurl -o /dev/null -X POST -H "$KH" "$AK/api/v1/polls/$p_poll_on_a/votes" -d 'choices[]=1'
|
||||
until_true 30 '[ "$(curl -s -H "$PH" "$P/api/v1/accounts/$alice_self/statuses" | j "print(next(s[\"poll\"][\"options\"][1][\"votes_count\"] for s in d if s[\"uri\"]==\"$p_poll_uri\"))")" = "1" ]' && ok "akuser's vote counts on PrivaPub" || ko "vote not counted on PrivaPub"
|
||||
a_poll=$(acurl -X POST -H "$KH" "$AK/api/v1/statuses" -d 'status=tea or coffee, Akkoma asks&visibility=public&poll[options][]=tea&poll[options][]=coffee&poll[expires_in]=3600' | j "print(d['id'])")
|
||||
until_true 30 'curl -s -H "$PH" "$P/api/v1/timelines/home" | j "print(any(s[\"poll\"] and \"Akkoma asks\" in s[\"content\"] for s in d))" | grep -q True' && ok "akuser's poll reaches PrivaPub as a poll" || ko "poll missing on PrivaPub"
|
||||
a_poll_on_p=$(curl -s -H "$PH" "$P/api/v1/timelines/home" | j "print(next(s['poll']['id'] for s in d if s['poll'] and 'Akkoma asks' in s['content']))")
|
||||
curl -s -o /dev/null -X POST -H "$PH" "$P/api/v1/polls/$a_poll_on_p/votes" -d 'choices[]=0'
|
||||
until_true 30 '[ "$(acurl -H "$KH" "$AK/api/v1/statuses/$a_poll" | j "print(d[\"poll\"][\"options\"][0][\"votes_count\"])")" = "1" ]' && ok "alice_akkoma's vote counts on Akkoma" || ko "vote not counted on Akkoma"
|
||||
|
||||
echo " quotes"
|
||||
a_quote=$(acurl -X POST -H "$KH" "$AK/api/v1/statuses" -d "status=quoting PrivaPub&visibility=public"e_id=$p_post_on_a" | j "print(d['id'])")
|
||||
until_true 30 'curl -s -H "$PH" "$P/api/v1/timelines/home" | j "print(any(\"quoting PrivaPub\" in s[\"content\"] and (s.get(\"quote\") or {}).get(\"state\")==\"accepted\" for s in d))" | grep -q True' \
|
||||
&& ok "akuser's quote arrives as an accepted quote" || ko "Akkoma's quote not understood"
|
||||
p_quote_uri=$(curl -s -X POST -H "$PH" $P/api/v1/statuses -d "status=quoting Akkoma&visibility=public"ed_status_id=$a_post_on_p" | j "print(d['uri'])")
|
||||
until_true 30 '[ "$(a_status_by_uri "$alice_on_a" "$p_quote_uri" | j "print(d and ((d.get(\"quote\") or {}).get(\"id\") or d.get(\"quote_id\") or (d.get(\"pleroma\") or {}).get(\"quote_id\")) == \"$a_post_id\")")" = "True" ]' \
|
||||
&& ok "alice_akkoma's quote is a quote on Akkoma" || ko "quote not understood by Akkoma"
|
||||
|
||||
echo " media"
|
||||
make_png "$work/red.png"
|
||||
p_media=$(curl -s -X POST -H "$PH" "$P/api/v2/media" -F "file=@$work/red.png;type=image/png" -F 'description=a red square' | j "print(d['id'])")
|
||||
p_media_uri=$(curl -s -X POST -H "$PH" $P/api/v1/statuses -d "status=a picture&visibility=public&media_ids[]=$p_media" | j "print(d['uri'])")
|
||||
until_true 30 '[ "$(a_status_by_uri "$alice_on_a" "$p_media_uri" | j "print(d[\"media_attachments\"][0][\"description\"])")" = "a red square" ]' && ok "an image with alt text reaches Akkoma" || ko "image or alt text missing on Akkoma"
|
||||
a_media=$(acurl -X POST -H "$KH" "$AK/api/v1/media" -F "file=@$work/red.png;type=image/png" -F 'description=a red square from Akkoma' | j "print(d['id'])")
|
||||
acurl -o /dev/null -X POST -H "$KH" "$AK/api/v1/statuses" -d "status=a picture from Akkoma&visibility=public&media_ids[]=$a_media"
|
||||
until_true 30 'curl -s -H "$PH" "$P/api/v1/timelines/home" | j "print(any(\"a picture from Akkoma\" in s[\"content\"] and s[\"media_attachments\"] and \"/media/proxy/\" in s[\"media_attachments\"][0][\"url\"] and s[\"media_attachments\"][0][\"description\"]==\"a red square from Akkoma\" for s in d))" | grep -q True' \
|
||||
&& ok "an image with alt text from Akkoma arrives through our proxy" || ko "Akkoma's image missing, unproxied or without alt text"
|
||||
|
||||
echo " edits and deletes"
|
||||
curl -s -o /dev/null -X PUT -H "$PH" "$P/api/v1/statuses/$p_post_id" -d 'status=Hello Akkoma from PrivaPub, edited'
|
||||
until_true 30 'acurl -H "$KH" "$AK/api/v1/statuses/$p_post_on_a" | grep -q "edited"' && ok "alice_akkoma's edit reaches Akkoma" || ko "edit not applied on Akkoma"
|
||||
acurl -o /dev/null -X PUT -H "$KH" "$AK/api/v1/statuses/$a_post_id" -d 'status=Hello PrivaPub from Akkoma, edited'
|
||||
until_true 30 '[ "$(curl -s -H "$PH" "$P/api/v1/statuses/$a_post_on_p/history" | j "print(len(d))")" = "2" ]' && ok "akuser's edit reaches PrivaPub with its history" || ko "Akkoma's edit not applied on PrivaPub"
|
||||
cw_on_a=$(a_status_by_uri "$alice_on_a" "$cw_uri" | j "print(d['id'])")
|
||||
cw_id=$(curl -s -H "$PH" "$P/api/v1/accounts/$alice_self/statuses" | j "print(next(s['id'] for s in d if s['uri']=='$cw_uri'))")
|
||||
curl -s -o /dev/null -X DELETE -H "$PH" "$P/api/v1/statuses/$cw_id"
|
||||
until_true 30 '[ "$(acurl -o /dev/null -w "%{http_code}" -H "$KH" "$AK/api/v1/statuses/$cw_on_a")" = "404" ]' && ok "alice_akkoma's delete reaches Akkoma" || ko "delete not applied on Akkoma"
|
||||
acurl -o /dev/null -X DELETE -H "$KH" "$AK/api/v1/statuses/$a_poll"
|
||||
until_true 30 '! curl -s -H "$PH" "$P/api/v1/timelines/home" | grep -q "Akkoma asks"' && ok "akuser's delete reaches PrivaPub" || ko "Akkoma's delete not applied on PrivaPub"
|
||||
|
||||
echo " blocks and unfollows"
|
||||
curl -s -o /dev/null -X POST -H "$PH" "$P/api/v1/accounts/$ak_on_p/unfollow"
|
||||
until_true 30 '[ "$(acurl -H "$KH" "$AK/api/v1/accounts/relationships?id[]=$alice_on_a" | j "print(d[0][\"followed_by\"])")" = "False" ]' && ok "alice_akkoma's unfollow reaches Akkoma" || ko "unfollow not applied on Akkoma"
|
||||
curl -s -o /dev/null -X POST -H "$PH" "$P/api/v1/accounts/$ak_on_p/block"
|
||||
# Akkoma never reports a remote blocker as blocked_by, so the block is read from its user_relationships (1 is a block)
|
||||
a_blocked() { podman exec pasture-postgres psql -U pasture -d akkoma -tAc "select count(*) from user_relationships r join users a on a.id=r.source_id join users b on b.id=r.target_id where r.relationship_type=1 and a.nickname='alice_akkoma@privapub.test' and b.nickname='akuser'"; }
|
||||
until_true 30 '[ "$(a_blocked)" = "1" ]' && ok "alice_akkoma's block reaches Akkoma" || ko "block not applied on Akkoma"
|
||||
curl -s -o /dev/null -X POST -H "$PH" "$P/api/v1/accounts/$ak_on_p/unblock"
|
||||
until_true 30 '[ "$(a_blocked)" = "0" ]' && ok "alice_akkoma's unblock reaches Akkoma" || ko "unblock not applied on Akkoma"
|
||||
|
||||
echo " statistics"
|
||||
stats_check akkoma.test akkoma
|
||||
@@ -101,6 +101,96 @@ curl -s -o /dev/null -X DELETE -H "$PH" $P/api/v1/statuses/$cw
|
||||
gcurl -s -o /dev/null -X DELETE -H "$GH" $G/api/v1/statuses/$gts_post
|
||||
until_true 20 '! curl -s -H "$PH" "$P/api/v1/timelines/home" | grep -q "Hello from GoToSocial"' && ok "gtsuser's delete reaches PrivaPub" || ko "remote delete not applied"
|
||||
|
||||
echo "more replies, edits and undos"
|
||||
gts_edit=$(gcurl -s -X POST -H "$GH" $G/api/v1/statuses -d 'status=GoToSocial before the edit&visibility=public' | j "print(d['id'])")
|
||||
until_true 20 'curl -s -H "$PH" "$P/api/v1/timelines/home" | grep -q "GoToSocial before the edit"' || true
|
||||
gts_edit_on_pp=$(curl -s -H "$PH" "$P/api/v1/timelines/home" | j "print(next(s['id'] for s in d if 'GoToSocial before the edit' in s['content']))")
|
||||
pp_reply=$(curl -s -X POST -H "$PH" $P/api/v1/statuses -d "status=@gtsuser@gts.test answering from PrivaPub&in_reply_to_id=$gts_edit_on_pp&visibility=public" | j "print(d['uri'])")
|
||||
until_true 20 '[ "$(on_gts "$pp_reply" | j "print(d[\"statuses\"][0][\"in_reply_to_id\"] == \"$gts_edit\")")" = "True" ]' && ok "alice's reply threads under gtsuser's post" || ko "outbound reply not threaded on GoToSocial"
|
||||
gcurl -s -o /dev/null -X PUT -H "$GH" $G/api/v1/statuses/$gts_edit -d 'status=GoToSocial after the edit'
|
||||
until_true 20 '[ "$(curl -s -H "$PH" "$P/api/v1/statuses/$gts_edit_on_pp/history" | j "print(len(d))")" = "2" ]' && ok "gtsuser's edit reaches PrivaPub with its history" || ko "GoToSocial's edit not applied"
|
||||
[ "$(curl -s -H "$PH" "$P/api/v1/statuses/$gts_edit_on_pp" | j "print(d['edited_at'] is not None)")" = "True" ] && ok "the edited post carries edited_at" || ko "no edited_at on the edited post"
|
||||
curl -s -o /dev/null -X POST -H "$PH" $P/api/v1/statuses/$gts_edit_on_pp/favourite
|
||||
curl -s -o /dev/null -X POST -H "$PH" $P/api/v1/statuses/$gts_edit_on_pp/reblog
|
||||
until_true 20 '[ "$(gcurl -s -H "$GH" "$G/api/v1/statuses/$gts_edit" | j "print(d[\"favourites_count\"], d[\"reblogs_count\"])")" = "1 1" ]' || true
|
||||
curl -s -o /dev/null -X POST -H "$PH" $P/api/v1/statuses/$gts_edit_on_pp/unfavourite
|
||||
curl -s -o /dev/null -X POST -H "$PH" $P/api/v1/statuses/$gts_edit_on_pp/unreblog
|
||||
until_true 20 '[ "$(gcurl -s -H "$GH" "$G/api/v1/statuses/$gts_edit" | j "print(d[\"favourites_count\"], d[\"reblogs_count\"])")" = "0 0" ]' && ok "alice's unlike and unboost reach GoToSocial" || ko "undo of like or boost not applied on GoToSocial"
|
||||
pp_undo=$(curl -s -X POST -H "$PH" $P/api/v1/statuses -d 'status=like me and take it back&visibility=public' | j "print(d['id'] + ' ' + d['uri'])")
|
||||
pp_undo_id=${pp_undo% *}; pp_undo_uri=${pp_undo#* }
|
||||
until_true 20 '[ -n "$(on_gts "$pp_undo_uri" | j "print(d[\"statuses\"][0][\"id\"])")" ]' || true
|
||||
pp_undo_on_gts=$(on_gts "$pp_undo_uri" | j "print(d['statuses'][0]['id'])")
|
||||
gcurl -s -o /dev/null -X POST -H "$GH" $G/api/v1/statuses/$pp_undo_on_gts/favourite
|
||||
gcurl -s -o /dev/null -X POST -H "$GH" $G/api/v1/statuses/$pp_undo_on_gts/reblog
|
||||
until_true 20 '[ "$(curl -s -H "$PH" "$P/api/v1/statuses/$pp_undo_id" | j "print(d[\"favourites_count\"], d[\"reblogs_count\"])")" = "1 1" ]' || true
|
||||
gcurl -s -o /dev/null -X POST -H "$GH" $G/api/v1/statuses/$pp_undo_on_gts/unfavourite
|
||||
gcurl -s -o /dev/null -X POST -H "$GH" $G/api/v1/statuses/$pp_undo_on_gts/unreblog
|
||||
until_true 20 '[ "$(curl -s -H "$PH" "$P/api/v1/statuses/$pp_undo_id" | j "print(d[\"favourites_count\"], d[\"reblogs_count\"])")" = "0 0" ]' && ok "gtsuser's unlike and unboost reach PrivaPub" || ko "GoToSocial's undo of like or boost not applied"
|
||||
|
||||
echo "media"
|
||||
make_png "$work/red.png"
|
||||
pp_media=$(curl -s -X POST -H "$PH" "$P/api/v2/media" -F "file=@$work/red.png;type=image/png" -F 'description=a red square' | j "print(d['id'])")
|
||||
pp_media_uri=$(curl -s -X POST -H "$PH" $P/api/v1/statuses -d "status=a picture for GoToSocial&visibility=public&media_ids[]=$pp_media" | j "print(d['uri'])")
|
||||
until_true 20 '[ "$(on_gts "$pp_media_uri" | j "print(d[\"statuses\"][0][\"media_attachments\"][0][\"description\"])")" = "a red square" ]' && ok "an image with alt text reaches GoToSocial" || ko "image or alt text missing on GoToSocial"
|
||||
g_media=$(gcurl -s -X POST -H "$GH" "$G/api/v2/media" -F "file=@$work/red.png;type=image/png" -F 'description=a red square from GoToSocial' | j "print(d['id'])")
|
||||
gcurl -s -o /dev/null -X POST -H "$GH" $G/api/v1/statuses -d "status=a picture from GoToSocial&visibility=public&media_ids[]=$g_media"
|
||||
until_true 20 'curl -s -H "$PH" "$P/api/v1/timelines/home" | j "print(any(\"a picture from GoToSocial\" in s[\"content\"] and s[\"media_attachments\"] and \"/media/proxy/\" in s[\"media_attachments\"][0][\"url\"] and s[\"media_attachments\"][0][\"description\"]==\"a red square from GoToSocial\" for s in d))" | grep -q True' \
|
||||
&& ok "an image with alt text from GoToSocial arrives through our proxy" || ko "GoToSocial's image missing, unproxied or without alt text"
|
||||
|
||||
echo "communities"
|
||||
jwt=$(privapub_root)
|
||||
alice_id=$(curl -s -H "$PH" "$P/api/v1/accounts/verify_credentials" | j "print(d['id'])")
|
||||
community_name="community$(date +%s)"
|
||||
community=$(curl -s -X POST $P/clientapi/group/insert -H 'Content-Type: application/json' -H "Authorization: Bearer $jwt" \
|
||||
-d "{\"avatarId\":\"$alice_id\",\"userName\":\"$community_name\",\"name\":\"a community\",\"description\":\"for everyone\",\"isCommunity\":true}" | j "print(d['id'])")
|
||||
community_on_gts=$(gcurl -s -H "$GH" "$G/api/v2/search?q=@$community_name@privapub.test&resolve=true&type=accounts" | j "print(d['accounts'][0]['id'])")
|
||||
gcurl -s -o /dev/null -X POST -H "$GH" $G/api/v1/accounts/$community_on_gts/follow
|
||||
until_true 20 '[ "$(gcurl -s -H "$GH" "$G/api/v1/accounts/relationships?id[]=$community_on_gts" | j "print(d[0][\"following\"])")" = "True" ]' && ok "gtsuser follows a PrivaPub community" || ko "community follow not accepted"
|
||||
curl -s -o /dev/null -X POST $P/clientapi/post/insert -H 'Content-Type: application/json' -H "Authorization: Bearer $jwt" \
|
||||
-d "{\"avatarId\":\"$alice_id\",\"title\":\"A community thread\",\"text\":\"posted into the community\",\"groupId\":\"$community\"}"
|
||||
community_post=$(podman exec pasture-mongo mongosh --quiet PrivaPub --eval 'print(db.Post.findOne({Text:/posted into the community/}).ObjectURI)')
|
||||
until_true 20 '[ "$(on_gts "$community_post" | j "print(len(d[\"statuses\"]))")" = "1" ]' && ok "the community's announce brings its post to GoToSocial" || ko "community post missing on GoToSocial"
|
||||
|
||||
echo "circles"
|
||||
circle_name="circle$(date +%s)"
|
||||
circle=$(curl -s -X POST $P/clientapi/group/insert -H 'Content-Type: application/json' -H "Authorization: Bearer $jwt" \
|
||||
-d "{\"avatarId\":\"$alice_id\",\"userName\":\"$circle_name\",\"name\":\"a circle\",\"description\":\"just us\",\"isCommunity\":false}" | j "print(d['id'])")
|
||||
circle_on_gts=$(gcurl -s -H "$GH" "$G/api/v2/search?q=@$circle_name@privapub.test&resolve=true&type=accounts" | j "print(d['accounts'][0]['id'])")
|
||||
gcurl -s -o /dev/null -X POST -H "$GH" $G/api/v1/accounts/$circle_on_gts/follow
|
||||
until_true 15 '[ "$(gcurl -s -H "$GH" "$G/api/v1/accounts/relationships?id[]=$circle_on_gts" | j "print(d[0][\"requested\"])")" = "True" ]' && ok "gtsuser's request to join a circle waits for its owner" || ko "joining the circle was not held for approval"
|
||||
requester=$(podman exec pasture-mongo mongosh --quiet PrivaPub --eval "print(db.Follower.findOne({LocalActorId:'$circle', IsAccepted:false}).ActorURI)")
|
||||
curl -s -o /dev/null -X POST $P/clientapi/group/approve -H 'Content-Type: application/json' -H "Authorization: Bearer $jwt" \
|
||||
-d "{\"avatarId\":\"$alice_id\",\"groupId\":\"$circle\",\"memberActorURI\":\"$requester\"}"
|
||||
until_true 20 '[ "$(gcurl -s -H "$GH" "$G/api/v1/accounts/relationships?id[]=$circle_on_gts" | j "print(d[0][\"following\"])")" = "True" ]' && ok "the owner's approval makes gtsuser a circle member" || ko "circle approval did not reach GoToSocial"
|
||||
curl -s -o /dev/null -X POST $P/clientapi/post/insert -H 'Content-Type: application/json' -H "Authorization: Bearer $jwt" \
|
||||
-d "{\"avatarId\":\"$alice_id\",\"text\":\"only the circle on GoToSocial sees this\",\"groupId\":\"$circle\"}"
|
||||
circle_uri=$(podman exec pasture-mongo mongosh --quiet PrivaPub --eval 'print(db.Post.findOne({Text:/only the circle on GoToSocial/}).ObjectURI)')
|
||||
if until_true 15 '[ "$(on_gts "$circle_uri" | j "print(len(d[\"statuses\"]))")" = "1" ]'; then
|
||||
ok "a circle post reaches its GoToSocial member"
|
||||
else
|
||||
xf "a circle post reaches its GoToSocial member (GoToSocial keeps no post addressed only to a collection it does not know)"
|
||||
fi
|
||||
[ "$(pfetch -o /dev/null -w '%{http_code}' -H 'Accept: application/activity+json' "$circle_uri")" = "404" ] && ok "the circle post is not served unsigned" || ko "circle post served unsigned"
|
||||
|
||||
echo "locked personas"
|
||||
LT=$(privapub_token locked_alice); LH="Authorization: Bearer $LT"
|
||||
curl -s -o /dev/null -X PATCH -H "$LH" "$P/api/v1/accounts/update_credentials" -d 'locked=true'
|
||||
locked_on_gts=$(gcurl -s -H "$GH" "$G/api/v2/search?q=@locked_alice@privapub.test&resolve=true&type=accounts" | j "print(d['accounts'][0]['id'])")
|
||||
gcurl -s -o /dev/null -X POST -H "$GH" $G/api/v1/accounts/$locked_on_gts/follow
|
||||
until_true 20 '[ "$(curl -s -H "$LH" "$P/api/v1/follow_requests" | j "print(len(d))")" = "1" ]' && ok "gtsuser's follow waits on a locked persona" || ko "no follow request on the locked persona"
|
||||
[ "$(gcurl -s -H "$GH" "$G/api/v1/accounts/relationships?id[]=$locked_on_gts" | j "print(d[0][\"requested\"])")" = "True" ] && ok "GoToSocial shows the follow as requested" || ko "GoToSocial does not show the request"
|
||||
requester=$(curl -s -H "$LH" "$P/api/v1/follow_requests" | j "print(d[0]['id'])")
|
||||
curl -s -o /dev/null -X POST -H "$LH" "$P/api/v1/follow_requests/$requester/reject"
|
||||
until_true 20 '[ "$(gcurl -s -H "$GH" "$G/api/v1/accounts/relationships?id[]=$locked_on_gts" | j "print(d[0][\"requested\"] or d[0][\"following\"])")" = "False" ]' && ok "a rejected request is withdrawn on GoToSocial" || ko "rejection not applied on GoToSocial"
|
||||
gcurl -s -o /dev/null -X POST -H "$GH" $G/api/v1/accounts/$locked_on_gts/follow
|
||||
until_true 20 '[ "$(curl -s -H "$LH" "$P/api/v1/follow_requests" | j "print(len(d))")" = "1" ]' || true
|
||||
requester=$(curl -s -H "$LH" "$P/api/v1/follow_requests" | j "print(d[0]['id'])")
|
||||
curl -s -o /dev/null -X POST -H "$LH" "$P/api/v1/follow_requests/$requester/authorize"
|
||||
until_true 20 '[ "$(gcurl -s -H "$GH" "$G/api/v1/accounts/relationships?id[]=$locked_on_gts" | j "print(d[0][\"following\"])")" = "True" ]' && ok "an authorized request makes gtsuser a follower" || ko "authorization not applied on GoToSocial"
|
||||
|
||||
echo "smoke"
|
||||
"$here/../smoke/mastodon-api.sh" "$P" "$PT" >/dev/null 2>&1 && ok "the deploy's Mastodon smoke check passes, signed in" || ko "the Mastodon smoke check fails"
|
||||
|
||||
echo "unfollow"
|
||||
curl -s -o /dev/null -X POST -H "$PH" $P/api/v1/accounts/$gts_on_pp/unfollow
|
||||
until_true 20 '[ "$(gcurl -s -H "$GH" "$G/api/v1/accounts/relationships?id[]=$alice_on_gts" | j "print(d[0][\"followed_by\"])")" = "False" ]' && ok "alice's unfollow reaches GoToSocial" || ko "unfollow not applied"
|
||||
|
||||
@@ -0,0 +1,114 @@
|
||||
# Lemmy 1.0: communities both ways (FEP-1b12), threads with titles, comments both ways, votes up and down both ways,
|
||||
# private messages both ways, a moderator's removal (P7), statistics. Lemmy is driven through its v4 API.
|
||||
LM=https://lemmy.test:6443
|
||||
LT=$(cat "$here/.state/lemmy.token" 2>/dev/null)
|
||||
# lm <method> <path> [json]: a Lemmy v4 API call as lemmyuser
|
||||
lm() {
|
||||
local method=$1 path=$2 body=${3:-}
|
||||
if [ -n "$body" ]; then
|
||||
site lemmy.test -s -X "$method" "$LM/api/v4/$path" -H "Authorization: Bearer $LT" -H 'Content-Type: application/json' -d "$body"
|
||||
else
|
||||
site lemmy.test -s -X "$method" "$LM/api/v4/$path" -H "Authorization: Bearer $LT"
|
||||
fi
|
||||
}
|
||||
# the posts Lemmy holds in a community, and the one whose ap_id is given
|
||||
lm_posts() { lm GET "post/list?community_id=$1&sort=new&limit=50" | j "print(json.dumps([p['post'] for p in (d.get('items') or d.get('posts') or [])]))"; }
|
||||
# 1 once Lemmy runs a send worker for privapub.test
|
||||
lm_worker() { podman exec pasture-postgres psql -U pasture -d lemmy -tAc "select count(*) from federation_queue_state q join instance i on i.id=q.instance_id where i.domain='privapub.test'"; }
|
||||
lm_post_by_ap() { lm_posts "$1" | j "print(json.dumps(next((p for p in d if p['ap_id']=='$2'), None)))"; }
|
||||
|
||||
echo "lemmy"
|
||||
[ -n "$LT" ] && ok "Lemmy token for lemmyuser" || { ko "Lemmy token"; return 1; }
|
||||
LAT=$(privapub_token alice_lemmy)
|
||||
LAH="Authorization: Bearer $LAT"
|
||||
[ -n "$LAT" ] && ok "PrivaPub token for alice_lemmy" || { ko "PrivaPub token for alice_lemmy"; return 1; }
|
||||
jwt=$(privapub_root)
|
||||
alice_id=$(curl -s -H "$LAH" "$P/api/v1/accounts/verify_credentials" | j "print(d['id'])")
|
||||
dogs="dogs$(date +%s)"
|
||||
cats="cats$(date +%s)"
|
||||
|
||||
echo " communities"
|
||||
dogs_gid=$(curl -s -X POST $P/clientapi/group/insert -H 'Content-Type: application/json' -H "Authorization: Bearer $jwt" \
|
||||
-d "{\"avatarId\":\"$alice_id\",\"userName\":\"$dogs\",\"name\":\"Pasture dogs\",\"description\":\"a PrivaPub community\",\"isCommunity\":true}" | j "print(d['id'])")
|
||||
dogs_on_lm=$(lm GET "resolve_object?q=!$dogs@privapub.test" | j "print(d['community']['id'])")
|
||||
[ -n "$dogs_on_lm" ] && ok "Lemmy resolves a PrivaPub community" || ko "Lemmy cannot resolve the PrivaPub community"
|
||||
# Lemmy 1.0 starts its send worker for a newly seen server at the newest activity and never sends anything queued for
|
||||
# that server before then, so on a clean pasture the first follow waits for the worker (up to a minute)
|
||||
until_true 45 '[ "$(lm_worker)" = "1" ]' || true
|
||||
lm POST community/follow "{\"community_id\":$dogs_on_lm,\"follow\":true}" >/dev/null
|
||||
until_true 30 '[ "$(lm GET "community?id=$dogs_on_lm" | j "print(((d[\"community_view\"].get(\"community_actions\") or {}).get(\"follow_state\") or \"\").lower())")" = "accepted" ]' \
|
||||
&& ok "lemmyuser follows the PrivaPub community (Accept arrived)" || ko "Lemmy's community follow not accepted"
|
||||
cats_on_lm=$(lm POST community "{\"name\":\"$cats\",\"title\":\"Pasture cats\"}" | j "print(d['community_view']['community']['id'])")
|
||||
cats_on_p=$(curl -s -H "$LAH" "$P/api/v2/search?q=@$cats@lemmy.test&resolve=true&type=accounts" | j "print(d['accounts'][0]['id'])")
|
||||
[ -n "$cats_on_p" ] && ok "PrivaPub resolves a Lemmy community" || ko "PrivaPub cannot resolve the Lemmy community"
|
||||
curl -s -o /dev/null -X POST -H "$LAH" "$P/api/v1/accounts/$cats_on_p/follow"
|
||||
until_true 30 '[ "$(curl -s -H "$LAH" "$P/api/v1/accounts/relationships?id[]=$cats_on_p" | j "print(d[0][\"following\"])")" = "True" ]' && ok "alice follows the Lemmy community (Accept arrived)" || ko "PrivaPub's community follow not accepted"
|
||||
|
||||
echo " threads"
|
||||
lm_thread=$(lm POST post "{\"name\":\"A Lemmy thread\",\"body\":\"posted from Lemmy into PrivaPub\",\"community_id\":$dogs_on_lm}" | j "print(d['post_view']['post']['ap_id'])")
|
||||
dogs_on_p=$(curl -s -H "$LAH" "$P/api/v1/accounts/lookup?acct=$dogs" | j "print(d['id'])")
|
||||
until_true 30 '[ "$(curl -s -H "$LAH" "$P/api/v1/accounts/$dogs_on_p/statuses" | j "print(any(s[\"uri\"]==\"$lm_thread\" and (s.get(\"privapub\") or {}).get(\"title\")==\"A Lemmy thread\" for s in d))")" = "True" ]' \
|
||||
&& ok "a Lemmy thread in the PrivaPub community arrives with its title" || ko "Lemmy's thread missing or untitled on PrivaPub"
|
||||
curl -s -o /dev/null -X POST $P/clientapi/post/insert -H 'Content-Type: application/json' -H "Authorization: Bearer $jwt" \
|
||||
-d "{\"avatarId\":\"$alice_id\",\"title\":\"A PrivaPub community thread\",\"text\":\"posted into our own community\",\"groupId\":\"$dogs_gid\"}"
|
||||
dogs_post=$(podman exec pasture-mongo mongosh --quiet PrivaPub --eval 'print(db.Post.findOne({Text:/posted into our own community/}, {}, {sort:{_id:-1}}).ObjectURI)')
|
||||
until_true 30 '[ "$(lm_post_by_ap "$dogs_on_lm" "$dogs_post" | j "print(d and d[\"name\"])")" = "A PrivaPub community thread" ]' \
|
||||
&& ok "a titled post in the PrivaPub community reaches Lemmy through its announce" || ko "PrivaPub community post missing on Lemmy"
|
||||
# Lemmy 1.0 holds its user's thread in a remote community as pending until the community announces it back, and clears
|
||||
# the flag even though it then answers that Announce 400 ("Object is not remote"): the echo is what publishes it.
|
||||
until_true 30 '[ "$(lm_post_by_ap "$dogs_on_lm" "$lm_thread" | j "print(d and d[\"federation_pending\"])")" = "False" ]' \
|
||||
&& ok "the PrivaPub community's announce back publishes Lemmy's thread there" || ko "Lemmy's thread stays federation_pending"
|
||||
lm_cats_thread=$(lm POST post "{\"name\":\"Cats only\",\"body\":\"a Lemmy community post\",\"community_id\":$cats_on_lm}" | j "print(d['post_view']['post']['ap_id'])")
|
||||
until_true 30 'curl -s -H "$LAH" "$P/api/v1/timelines/home" | j "print(any(s[\"uri\"]==\"$lm_cats_thread\" or (s.get(\"reblog\") or {}).get(\"uri\")==\"$lm_cats_thread\" for s in d))" | grep -q True' \
|
||||
&& ok "the Lemmy community's announce brings its thread to alice's home" || ko "Lemmy community thread missing from alice's home"
|
||||
p_thread=$(curl -s -X POST -H "$LAH" $P/api/v1/statuses --data-urlencode "status=A PrivaPub thread
|
||||
@$cats@lemmy.test posted from PrivaPub" -d 'visibility=public')
|
||||
p_thread_id=$(echo "$p_thread" | j "print(d['id'])"); p_thread_uri=$(echo "$p_thread" | j "print(d['uri'])")
|
||||
until_true 30 '[ "$(lm_post_by_ap "$cats_on_lm" "$p_thread_uri")" != "null" ]' && ok "alice's thread lands in the Lemmy community" || ko "PrivaPub thread missing from the Lemmy community"
|
||||
p_thread_on_lm=$(lm_post_by_ap "$cats_on_lm" "$p_thread_uri" | j "print(d['id'])")
|
||||
|
||||
echo " comments"
|
||||
lm POST comment "{\"post_id\":$p_thread_on_lm,\"content\":\"a Lemmy comment on PrivaPub\"}" >/dev/null
|
||||
until_true 30 '[ "$(curl -s -H "$LAH" "$P/api/v1/statuses/$p_thread_id/context" | j "print(any(\"a Lemmy comment on PrivaPub\" in s[\"content\"] for s in d[\"descendants\"]))")" = "True" ]' \
|
||||
&& ok "Lemmy's comment threads under alice's post" || ko "Lemmy's comment missing on PrivaPub"
|
||||
lm_cats_on_p=$(curl -s -H "$LAH" "$P/api/v1/timelines/home" | j "print(next((s.get('reblog') or s)['id'] for s in d if (s.get('reblog') or s)['uri']=='$lm_cats_thread'))")
|
||||
curl -s -o /dev/null -X POST -H "$LAH" $P/api/v1/statuses -d "status=a PrivaPub comment on Lemmy&in_reply_to_id=$lm_cats_on_p&visibility=public"
|
||||
lm_cats_id=$(lm GET "resolve_object?q=$lm_cats_thread" | j "print(d['post']['id'])")
|
||||
until_true 30 '[ "$(lm GET "comment/list?post_id=$lm_cats_id" | j "print(any(\"a PrivaPub comment on Lemmy\" in c[\"comment\"][\"content\"] for c in (d.get(\"items\") or d.get(\"comments\") or [])))")" = "True" ]' \
|
||||
&& ok "alice's reply becomes a comment on Lemmy" || ko "PrivaPub reply missing as a Lemmy comment"
|
||||
|
||||
echo " votes"
|
||||
# Lemmy sends a vote only to the community, which relays it inside an Announce: P7 trusts those for the community's own posts.
|
||||
lm POST post/like "{\"post_id\":$p_thread_on_lm,\"is_upvote\":true}" >/dev/null
|
||||
if until_true 15 '[ "$(curl -s -H "$LAH" "$P/api/v1/statuses/$p_thread_id" | j "print(d[\"favourites_count\"])")" = "1" ]'; then
|
||||
ok "Lemmy's upvote counts as a like on PrivaPub"
|
||||
else
|
||||
xf "Lemmy's upvote counts as a like on PrivaPub (votes relayed by a community are P7)"
|
||||
fi
|
||||
lm POST post/like "{\"post_id\":$p_thread_on_lm,\"is_upvote\":false}" >/dev/null
|
||||
if until_true 15 '[ "$(curl -s -H "$LAH" "$P/api/v1/statuses/$p_thread_id" | j "print(d[\"favourites_count\"], ((d.get(\"privapub\") or {}).get(\"votes\") or {}).get(\"down\"))")" = "0 1" ]'; then
|
||||
ok "Lemmy's change to a downvote reaches PrivaPub"
|
||||
else
|
||||
xf "Lemmy's change to a downvote reaches PrivaPub (votes relayed by a community are P7)"
|
||||
fi
|
||||
curl -s -o /dev/null -X POST -H "$LAH" "$P/api/v1/statuses/$lm_cats_on_p/favourite"
|
||||
until_true 30 '[ "$(lm GET "post?id=$lm_cats_id" | j "print(d[\"post_view\"][\"post\"].get(\"upvotes\"))")" = "2" ]' && ok "alice's like is an upvote on Lemmy" || ko "like not counted as an upvote on Lemmy"
|
||||
|
||||
echo " private messages"
|
||||
alice_on_lm=$(lm GET "resolve_object?q=@alice_lemmy@privapub.test" | j "print(d['person']['id'])")
|
||||
lm POST private_message "{\"content\":\"a secret from Lemmy\",\"recipient_id\":$alice_on_lm}" >/dev/null
|
||||
until_true 30 'curl -s -H "$LAH" "$P/api/v1/conversations" | grep -q "a secret from Lemmy"' && ok "Lemmy's private message arrives as a DM" || ko "Lemmy's private message missing on PrivaPub"
|
||||
curl -s -o /dev/null -X POST -H "$LAH" $P/api/v1/statuses -d 'status=@lemmyuser@lemmy.test a secret from PrivaPub&visibility=direct'
|
||||
until_true 30 'lm GET "account/notification/list?limit=50" | grep -q "a secret from PrivaPub"' && ok "alice's DM arrives as a Lemmy private message" || ko "DM missing on Lemmy"
|
||||
|
||||
echo " moderation"
|
||||
lm POST post/remove "{\"post_id\":$lm_cats_id,\"removed\":true,\"reason\":\"pasture\"}" >/dev/null
|
||||
sleep 10
|
||||
if curl -s -H "$LAH" "$P/api/v1/timelines/home" | j "print(any((s.get('reblog') or s)['uri']=='$lm_cats_thread' for s in d))" | grep -q False; then
|
||||
ok "a moderator's removal reaches PrivaPub"
|
||||
else
|
||||
xf "a moderator's removal reaches PrivaPub (the Lemmy moderation set is P7)"
|
||||
fi
|
||||
|
||||
echo " statistics"
|
||||
stats_check lemmy.test lemmy
|
||||
@@ -93,12 +93,7 @@ m_quote=$(mcurl -X POST -H "$MH" "$M/api/v1/statuses" -d "status=quoting PrivaPu
|
||||
until_true 30 '[ "$(mcurl -H "$MH" "$M/api/v1/statuses/$m_quote" | j "print((d.get(\"quote\") or {}).get(\"state\"))")" = "accepted" ]' && ok "PrivaPub approves mastouser's quote (FEP-044f)" || ko "quote of a PrivaPub post not approved on Mastodon"
|
||||
|
||||
echo " media"
|
||||
python3 -c "
|
||||
import struct,zlib
|
||||
w=h=8
|
||||
raw=b''.join(b'\x00'+bytes([200,60,60])*w for _ in range(h))
|
||||
png=b'\x89PNG\r\n\x1a\n'+b''.join(struct.pack('>I',len(c))+t+c+struct.pack('>I',zlib.crc32(t+c)&0xffffffff) for t,c in [(b'IHDR',struct.pack('>IIBBBBB',w,h,8,2,0,0,0)),(b'IDAT',zlib.compress(raw)),(b'IEND',b'')])
|
||||
open('$work/red.png','wb').write(png)"
|
||||
make_png "$work/red.png"
|
||||
a_media=$(curl -s -X POST -H "$AH" "$P/api/v2/media" -F "file=@$work/red.png;type=image/png" -F 'description=a red square' | j "print(d['id'])")
|
||||
a_media_uri=$(curl -s -X POST -H "$AH" $P/api/v1/statuses -d "status=a picture&visibility=public&media_ids[]=$a_media" | j "print(d['uri'])")
|
||||
until_true 30 '[ "$(m_status_by_uri "$alice_on_m" "$a_media_uri" | j "print(d[\"media_attachments\"][0][\"description\"])")" = "a red square" ]' && ok "an image with alt text reaches Mastodon" || ko "image or alt text missing on Mastodon"
|
||||
|
||||
@@ -0,0 +1,124 @@
|
||||
# Misskey 2026.10 (or Sharkey, with MISSKEY_NAME=sharkey): discovery, follows, posts and CW, replies both ways, Misskey
|
||||
# reactions both ways, legacy quotes both ways, MFM source, polls, specified notes, media, deletes, blocks, statistics.
|
||||
# A delivery is never checked with ap/show, which would fetch it from us; only with what Misskey already holds.
|
||||
MK_NAME=${MISSKEY_NAME:-misskey}
|
||||
MKH="$MK_NAME.test"
|
||||
MK="https://$MKH:6443"
|
||||
MKT=$(cat "$here/.state/$MK_NAME.token" 2>/dev/null)
|
||||
# mk <endpoint> [json]: a Misskey API call as mkuser
|
||||
mk() {
|
||||
local body=${2:-'{}'}
|
||||
site "$MKH" -s -X POST "$MK/api/$1" -H 'Content-Type: application/json' \
|
||||
-d "$(python3 -c "import sys,json; d=json.loads(sys.argv[1]); d['i']=sys.argv[2]; print(json.dumps(d))" "$body" "$MKT")"
|
||||
}
|
||||
# the notes Misskey holds of an account, and the one whose uri is given
|
||||
mk_notes() { mk users/notes "{\"userId\":\"$1\",\"limit\":50,\"withReplies\":true,\"withRenotes\":true}"; }
|
||||
# users/relation answers a list even for one user
|
||||
mk_relation() { mk users/relation "{\"userId\":\"$1\"}" | j "r=d[0] if isinstance(d,list) else d; print(r['$2'])"; }
|
||||
mk_note_by_uri() { mk_notes "$1" | j "print(json.dumps(next((n for n in d if n.get('uri')=='$2'), None)))"; }
|
||||
|
||||
echo "$MK_NAME"
|
||||
[ -n "$MKT" ] && ok "$MK_NAME token for mkuser" || { ko "$MK_NAME token"; return 1; }
|
||||
KT=$(privapub_token "alice_$MK_NAME")
|
||||
KH="Authorization: Bearer $KT"
|
||||
[ -n "$KT" ] && ok "PrivaPub token for alice_$MK_NAME" || { ko "PrivaPub token for alice_$MK_NAME"; return 1; }
|
||||
|
||||
echo " discovery"
|
||||
alice_on_mk=$(mk users/show "{\"username\":\"alice_$MK_NAME\",\"host\":\"privapub.test\"}" | j "print(d['id'])")
|
||||
[ -n "$alice_on_mk" ] && ok "$MK_NAME resolves @alice_$MK_NAME@privapub.test" || ko "$MK_NAME cannot resolve alice"
|
||||
mk_on_p=$(curl -s -H "$KH" "$P/api/v2/search?q=mkuser@$MKH&resolve=true&type=accounts" | j "print(d['accounts'][0]['id'])")
|
||||
[ -n "$mk_on_p" ] && ok "PrivaPub resolves @mkuser@$MKH" || ko "PrivaPub cannot resolve mkuser"
|
||||
|
||||
echo " follows"
|
||||
mk following/create "{\"userId\":\"$alice_on_mk\"}" >/dev/null
|
||||
until_true 30 '[ "$(mk_relation "$alice_on_mk" isFollowing)" = "True" ]' && ok "mkuser follows alice (Accept arrived)" || ko "$MK_NAME's follow not accepted"
|
||||
curl -s -o /dev/null -X POST -H "$KH" "$P/api/v1/accounts/$mk_on_p/follow"
|
||||
until_true 30 '[ "$(curl -s -H "$KH" "$P/api/v1/accounts/relationships?id[]=$mk_on_p" | j "print(d[0][\"following\"])")" = "True" ]' && ok "alice follows mkuser (Accept arrived)" || ko "PrivaPub's follow not accepted"
|
||||
|
||||
echo " posts"
|
||||
k_post=$(curl -s -X POST -H "$KH" $P/api/v1/statuses -d "status=Hello $MK_NAME from PrivaPub&visibility=public")
|
||||
k_post_id=$(echo "$k_post" | j "print(d['id'])"); k_post_uri=$(echo "$k_post" | j "print(d['uri'])")
|
||||
until_true 30 '[ "$(mk_note_by_uri "$alice_on_mk" "$k_post_uri")" != "null" ]' && ok "alice's post reaches $MK_NAME" || ko "post missing on $MK_NAME"
|
||||
k_post_on_mk=$(mk_note_by_uri "$alice_on_mk" "$k_post_uri" | j "print(d['id'])")
|
||||
mk_post=$(mk notes/create "{\"text\":\"Hello PrivaPub from $MK_NAME, \$[x2 big] words\",\"visibility\":\"public\"}" | j "print(d['createdNote']['id'])")
|
||||
until_true 30 'curl -s -H "$KH" "$P/api/v1/timelines/home" | grep -q "Hello PrivaPub from $MK_NAME"' && ok "mkuser's post reaches alice's home" || ko "$MK_NAME's post missing on PrivaPub"
|
||||
mk_post_on_p=$(curl -s -H "$KH" "$P/api/v1/timelines/home" | j "print(next(s['id'] for s in d if 'Hello PrivaPub from $MK_NAME' in s['content']))")
|
||||
[ "$(curl -s -H "$KH" "$P/api/v1/statuses/$mk_post_on_p" | j "print('\$[x2' in ((d.get('privapub') or {}).get('source') or {}).get('content',''))")" = "True" ] \
|
||||
&& ok "the MFM source is kept" || ko "MFM source lost"
|
||||
cw_uri=$(curl -s -X POST -H "$KH" $P/api/v1/statuses -d 'status=behind a warning&spoiler_text=spoilers&visibility=public' | j "print(d['uri'])")
|
||||
until_true 30 '[ "$(mk_note_by_uri "$alice_on_mk" "$cw_uri" | j "print(d and d[\"cw\"])")" = "spoilers" ]' && ok "a content warning survives to $MK_NAME" || ko "content warning lost on $MK_NAME"
|
||||
|
||||
echo " replies"
|
||||
mk notes/create "{\"text\":\"@alice_$MK_NAME@privapub.test replying from $MK_NAME\",\"replyId\":\"$k_post_on_mk\",\"visibility\":\"public\"}" >/dev/null
|
||||
until_true 30 'curl -s -H "$KH" "$P/api/v1/notifications" | j "print(any(n[\"type\"]==\"mention\" for n in d))" | grep -q True' && ok "mkuser's reply notifies alice" || ko "reply did not notify"
|
||||
until_true 15 '[ "$(curl -s -H "$KH" "$P/api/v1/statuses/$k_post_id/context" | j "print(len(d[\"descendants\"]))")" -ge 1 ]' && ok "the reply threads under alice's post" || ko "reply not threaded on PrivaPub"
|
||||
k_reply_uri=$(curl -s -X POST -H "$KH" $P/api/v1/statuses -d "status=@mkuser@$MKH replying from PrivaPub&in_reply_to_id=$mk_post_on_p&visibility=public" | j "print(d['uri'])")
|
||||
until_true 30 '[ "$(mk_note_by_uri "$alice_on_mk" "$k_reply_uri" | j "print(d and d[\"replyId\"])")" = "$mk_post" ]' && ok "alice's reply threads under mkuser's note" || ko "outbound reply not threaded on $MK_NAME"
|
||||
|
||||
echo " reactions"
|
||||
mk notes/reactions/create "{\"noteId\":\"$k_post_on_mk\",\"reaction\":\"👍\"}" >/dev/null
|
||||
until_true 30 'curl -s -H "$KH" "$P/api/v1/pleroma/statuses/$k_post_id/reactions" | j "print(any(r[\"name\"]==\"👍\" and r[\"count\"]==1 for r in d))" | grep -q True' && ok "mkuser's 👍 reaction reaches PrivaPub" || ko "reaction missing on PrivaPub"
|
||||
mk notes/reactions/delete "{\"noteId\":\"$k_post_on_mk\"}" >/dev/null
|
||||
until_true 30 '[ "$(curl -s -H "$KH" "$P/api/v1/pleroma/statuses/$k_post_id/reactions" | j "print(len(d))")" = "0" ]' && ok "mkuser's withdrawn reaction leaves PrivaPub" || ko "reaction not withdrawn on PrivaPub"
|
||||
curl -s -o /dev/null -X PUT -H "$KH" "$P/api/v1/pleroma/statuses/$mk_post_on_p/reactions/%F0%9F%8E%89"
|
||||
until_true 30 '[ "$(mk notes/show "{\"noteId\":\"$mk_post\"}" | j "print(d[\"reactions\"].get(\"🎉\", 0))")" = "1" ]' && ok "alice's 🎉 reaction reaches $MK_NAME" || ko "reaction missing on $MK_NAME"
|
||||
# Misskey keeps one reaction per user and note, so the like goes to a note of its own
|
||||
mk_liked=$(mk notes/create "{\"text\":\"like this one, PrivaPub\",\"visibility\":\"public\"}" | j "print(d['createdNote']['id'])")
|
||||
until_true 30 'curl -s -H "$KH" "$P/api/v1/timelines/home" | grep -q "like this one, PrivaPub"' || true
|
||||
mk_liked_on_p=$(curl -s -H "$KH" "$P/api/v1/timelines/home" | j "print(next(s['id'] for s in d if 'like this one, PrivaPub' in s['content']))")
|
||||
curl -s -o /dev/null -X POST -H "$KH" "$P/api/v1/statuses/$mk_liked_on_p/favourite"
|
||||
until_true 30 '[ "$(mk notes/show "{\"noteId\":\"$mk_liked\"}" | j "print(sum(d[\"reactions\"].values()))")" = "1" ]' && ok "alice's like counts as a reaction on $MK_NAME" || ko "like not counted on $MK_NAME"
|
||||
|
||||
echo " quotes"
|
||||
mk_quote=$(mk notes/create "{\"text\":\"quoting PrivaPub\",\"renoteId\":\"$k_post_on_mk\",\"visibility\":\"public\"}" | j "print(d['createdNote']['id'])")
|
||||
until_true 30 'curl -s -H "$KH" "$P/api/v1/timelines/home" | j "print(any(\"quoting PrivaPub\" in s[\"content\"] and (s.get(\"quote\") or {}).get(\"state\")==\"accepted\" for s in d))" | grep -q True' \
|
||||
&& ok "mkuser's quote arrives as an accepted quote" || ko "$MK_NAME's quote not understood"
|
||||
k_quote_uri=$(curl -s -X POST -H "$KH" $P/api/v1/statuses -d "status=quoting $MK_NAME&visibility=public"ed_status_id=$mk_post_on_p" | j "print(d['uri'])")
|
||||
until_true 30 '[ "$(mk_note_by_uri "$alice_on_mk" "$k_quote_uri" | j "print(d and d[\"renoteId\"])")" = "$mk_post" ]' && ok "alice's quote is a quote on $MK_NAME" || ko "quote not understood by $MK_NAME"
|
||||
|
||||
echo " polls"
|
||||
k_poll_uri=$(curl -s -X POST -H "$KH" $P/api/v1/statuses -d 'status=cats or dogs&visibility=public&poll[options][]=cats&poll[options][]=dogs&poll[expires_in]=3600' | j "print(d['uri'])")
|
||||
until_true 30 '[ "$(mk_note_by_uri "$alice_on_mk" "$k_poll_uri" | j "print(len(d[\"poll\"][\"choices\"]))")" = "2" ]' && ok "alice's poll reaches $MK_NAME as a poll" || ko "poll missing on $MK_NAME"
|
||||
k_poll_on_mk=$(mk_note_by_uri "$alice_on_mk" "$k_poll_uri" | j "print(d['id'])")
|
||||
mk notes/polls/vote "{\"noteId\":\"$k_poll_on_mk\",\"choice\":1}" >/dev/null
|
||||
alice_self=$(curl -s -H "$KH" "$P/api/v1/accounts/verify_credentials" | j "print(d['id'])")
|
||||
until_true 30 '[ "$(curl -s -H "$KH" "$P/api/v1/accounts/$alice_self/statuses" | j "print(next(s[\"poll\"][\"options\"][1][\"votes_count\"] for s in d if s[\"uri\"]==\"$k_poll_uri\"))")" = "1" ]' && ok "mkuser's vote counts on PrivaPub" || ko "vote not counted on PrivaPub"
|
||||
mk_poll=$(mk notes/create "{\"text\":\"tea or coffee, $MK_NAME asks\",\"visibility\":\"public\",\"poll\":{\"choices\":[\"tea\",\"coffee\"],\"expiredAfter\":3600000}}" | j "print(d['createdNote']['id'])")
|
||||
until_true 30 'curl -s -H "$KH" "$P/api/v1/timelines/home" | j "print(any(s[\"poll\"] and \"$MK_NAME asks\" in s[\"content\"] for s in d))" | grep -q True' && ok "mkuser's poll reaches PrivaPub as a poll" || ko "poll missing on PrivaPub"
|
||||
mk_poll_on_p=$(curl -s -H "$KH" "$P/api/v1/timelines/home" | j "print(next(s['poll']['id'] for s in d if s['poll'] and '$MK_NAME asks' in s['content']))")
|
||||
curl -s -o /dev/null -X POST -H "$KH" "$P/api/v1/polls/$mk_poll_on_p/votes" -d 'choices[]=0'
|
||||
until_true 30 '[ "$(mk notes/show "{\"noteId\":\"$mk_poll\"}" | j "print(d[\"poll\"][\"choices\"][0][\"votes\"])")" = "1" ]' && ok "alice's vote counts on $MK_NAME" || ko "vote not counted on $MK_NAME"
|
||||
|
||||
echo " specified notes"
|
||||
mk notes/create "{\"text\":\"@alice_$MK_NAME@privapub.test a secret for PrivaPub\",\"visibility\":\"specified\",\"visibleUserIds\":[\"$alice_on_mk\"]}" >/dev/null
|
||||
until_true 30 'curl -s -H "$KH" "$P/api/v1/conversations" | grep -q "a secret for PrivaPub"' && ok "mkuser's specified note arrives as a DM" || ko "specified note missing on PrivaPub"
|
||||
curl -s -o /dev/null -X POST -H "$KH" $P/api/v1/statuses -d "status=@mkuser@$MKH a secret for $MK_NAME&visibility=direct"
|
||||
until_true 30 'mk notes/mentions "{\"visibility\":\"specified\"}" | grep -q "a secret for $MK_NAME"' && ok "alice's DM arrives as a specified note" || ko "DM missing on $MK_NAME"
|
||||
|
||||
echo " media"
|
||||
make_png "$work/red.png"
|
||||
k_media=$(curl -s -X POST -H "$KH" "$P/api/v2/media" -F "file=@$work/red.png;type=image/png" -F 'description=a red square' | j "print(d['id'])")
|
||||
k_media_uri=$(curl -s -X POST -H "$KH" $P/api/v1/statuses -d "status=a picture for $MK_NAME&visibility=public&media_ids[]=$k_media" | j "print(d['uri'])")
|
||||
until_true 30 '[ "$(mk_note_by_uri "$alice_on_mk" "$k_media_uri" | j "print(d[\"files\"][0][\"comment\"])")" = "a red square" ]' && ok "an image with alt text reaches $MK_NAME" || ko "image or alt text missing on $MK_NAME"
|
||||
mk_file=$(site "$MKH" -s -X POST "$MK/api/drive/files/create" -F "i=$MKT" -F "file=@$work/red.png;type=image/png" -F "comment=a red square from $MK_NAME" | j "print(d['id'])")
|
||||
mk notes/create "{\"text\":\"a picture from $MK_NAME\",\"visibility\":\"public\",\"fileIds\":[\"$mk_file\"]}" >/dev/null
|
||||
until_true 30 'curl -s -H "$KH" "$P/api/v1/timelines/home" | j "print(any(\"a picture from $MK_NAME\" in s[\"content\"] and s[\"media_attachments\"] and \"/media/proxy/\" in s[\"media_attachments\"][0][\"url\"] and s[\"media_attachments\"][0][\"description\"]==\"a red square from $MK_NAME\" for s in d))" | grep -q True' \
|
||||
&& ok "an image with alt text from $MK_NAME arrives through our proxy" || ko "$MK_NAME's image missing, unproxied or without alt text"
|
||||
|
||||
echo " deletes"
|
||||
cw_on_mk=$(mk_note_by_uri "$alice_on_mk" "$cw_uri" | j "print(d['id'])")
|
||||
cw_id=$(curl -s -H "$KH" "$P/api/v1/accounts/$alice_self/statuses" | j "print(next(s['id'] for s in d if s['uri']=='$cw_uri'))")
|
||||
curl -s -o /dev/null -X DELETE -H "$KH" "$P/api/v1/statuses/$cw_id"
|
||||
until_true 30 '[ "$(mk notes/show "{\"noteId\":\"$cw_on_mk\"}" | j "print(d.get(\"error\",{}).get(\"code\"))")" = "NO_SUCH_NOTE" ]' && ok "alice's delete reaches $MK_NAME" || ko "delete not applied on $MK_NAME"
|
||||
mk notes/delete "{\"noteId\":\"$mk_poll\"}" >/dev/null
|
||||
until_true 30 '! curl -s -H "$KH" "$P/api/v1/timelines/home" | grep -q "$MK_NAME asks"' && ok "mkuser's delete reaches PrivaPub" || ko "$MK_NAME's delete not applied on PrivaPub"
|
||||
|
||||
echo " unfollows and blocks"
|
||||
curl -s -o /dev/null -X POST -H "$KH" "$P/api/v1/accounts/$mk_on_p/unfollow"
|
||||
until_true 30 '[ "$(mk_relation "$alice_on_mk" isFollowed)" = "False" ]' && ok "alice's unfollow reaches $MK_NAME" || ko "unfollow not applied on $MK_NAME"
|
||||
curl -s -o /dev/null -X POST -H "$KH" "$P/api/v1/accounts/$mk_on_p/block"
|
||||
until_true 30 '[ "$(mk_relation "$alice_on_mk" isBlocked)" = "True" ]' && ok "alice's block reaches $MK_NAME" || ko "block not applied on $MK_NAME"
|
||||
curl -s -o /dev/null -X POST -H "$KH" "$P/api/v1/accounts/$mk_on_p/unblock"
|
||||
|
||||
echo " statistics"
|
||||
stats_check "$MKH" "$MK_NAME"
|
||||
@@ -0,0 +1,25 @@
|
||||
# Sharkey 2025.4: Misskey's scenario under Sharkey's name, then what only Sharkey does: edits both ways, and quotes that
|
||||
# carry a FEP-e232 Link tag instead of `quote`.
|
||||
MISSKEY_NAME=sharkey
|
||||
. "$here/scenarios/misskey.sh"
|
||||
unset MISSKEY_NAME
|
||||
|
||||
echo " sharkey only"
|
||||
# Misskey's scenario ends unfollowed and blocked, so follow again both ways
|
||||
curl -s -o /dev/null -X POST -H "$KH" "$P/api/v1/accounts/$mk_on_p/follow"
|
||||
mk following/create "{\"userId\":\"$alice_on_mk\"}" >/dev/null
|
||||
until_true 30 '[ "$(mk_relation "$alice_on_mk" isFollowed)$(mk_relation "$alice_on_mk" isFollowing)" = "TrueTrue" ]' \
|
||||
&& ok "alice and mkuser follow each other again" || ko "refollow after the block failed"
|
||||
sk_note=$(mk notes/create '{"text":"before the edit","visibility":"public"}' | j "print(d['createdNote']['id'])")
|
||||
until_true 30 'curl -s -H "$KH" "$P/api/v1/timelines/home" | grep -q "before the edit"' && ok "a Sharkey note reaches alice" || ko "Sharkey note missing on PrivaPub"
|
||||
mk notes/edit "{\"editId\":\"$sk_note\",\"text\":\"after the edit\",\"visibility\":\"public\"}" >/dev/null
|
||||
until_true 30 '[ "$(curl -s -H "$KH" "$P/api/v1/timelines/home" | j "print(any(\"after the edit\" in s[\"content\"] and s[\"edited_at\"] for s in d))")" = "True" ]' \
|
||||
&& ok "mkuser's edit arrives as an edit" || ko "Sharkey edit not applied on PrivaPub"
|
||||
k_edit=$(curl -s -X POST -H "$KH" $P/api/v1/statuses -d 'status=PrivaPub before the edit&visibility=public')
|
||||
k_edit_id=$(echo "$k_edit" | j "print(d['id'])"); k_edit_uri=$(echo "$k_edit" | j "print(d['uri'])")
|
||||
until_true 30 '[ "$(mk_note_by_uri "$alice_on_mk" "$k_edit_uri")" != "null" ]' || true
|
||||
curl -s -o /dev/null -X PUT -H "$KH" "$P/api/v1/statuses/$k_edit_id" -d 'status=PrivaPub after the edit'
|
||||
until_true 30 '[ "$(mk_note_by_uri "$alice_on_mk" "$k_edit_uri" | j "print(d and d[\"text\"])")" = "PrivaPub after the edit" ]' \
|
||||
&& ok "alice's edit reaches Sharkey" || ko "PrivaPub edit not applied on Sharkey"
|
||||
quote_tag=$(podman exec pasture-mongo mongosh --quiet PrivaPub --eval 'var p=db.Post.findOne({Text:/quoting PrivaPub/}, {}, {sort:{_id:-1}}); var r=p && db.ObjectRecord.findOne({PostId:p._id.toHexString()}); print(r ? /"type"\s*:\s*"Link"/.test(r.Raw) : "none")')
|
||||
[ "$quote_tag" = "true" ] && ok "Sharkey's quote carries a FEP-e232 Link tag, and is understood" || ko "Sharkey's quote record has no Link tag ($quote_tag)"
|
||||
Reference in new issue
Block a user