From d4e9acdb795c884e05c2270da2e3b3525b2e2223 Mon Sep 17 00:00:00 2001 From: thepra Date: Sat, 3 Oct 2026 12:54:01 +0200 Subject: [PATCH] T10: what GoToSocial had not yet been asked, and quick edits that were lost The GoToSocial scenario gains 17 checks: - alice's reply threads under gtsuser's post; - gtsuser's edit arrives with edited_at and two history entries; - unlike and unboost both ways; - images with alt text both ways, theirs through our proxy; - gtsuser follows a PrivaPub community and its announce brings the post; - gtsuser's request to join a circle waits for the owner and is approved, and the circle post is never served unsigned; - a locked persona holds gtsuser's follow, rejects it, then authorizes it; - the deploy's Mastodon smoke check passes, signed in. 54 checks passed, three runs in a row. The circle post reaching gtsuser is an expected failure: GoToSocial keeps no post addressed only to a collection it does not know. It found a bug. An edit made within the second the post was published carries GoToSocial's whole-second updated == published, and IsEdit wanted strictly newer, so the edit was taken as a refresh and lost. A first edit now also counts when it is no older and the text, warning or title actually changed. A bare refresh still never makes a revision. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01ELjqpznMFMNrJoJUj6K5p2 --- CLAUDE.md | 3 +- PrivaPub.Tests/Federation/RemoteEditsTests.cs | 38 ++++++++ PrivaPub/Federation/Inbox/RemoteEdits.cs | 9 +- tools/pasture/lib/interop.sh | 8 ++ tools/pasture/scenarios/gts.sh | 90 +++++++++++++++++++ tools/pasture/scenarios/mastodon.sh | 7 +- 6 files changed, 147 insertions(+), 8 deletions(-) create mode 100644 PrivaPub.Tests/Federation/RemoteEditsTests.cs diff --git a/CLAUDE.md b/CLAUDE.md index 9b53df7..2c1ae90 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -281,7 +281,8 @@ 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. diff --git a/PrivaPub.Tests/Federation/RemoteEditsTests.cs b/PrivaPub.Tests/Federation/RemoteEditsTests.cs new file mode 100644 index 0000000..3198232 --- /dev/null +++ b/PrivaPub.Tests/Federation/RemoteEditsTests.cs @@ -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 = "

before

" }; + + [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 = "

before

" }, Stored())); + Assert.False(RemoteEdits.IsEdit(new NoteDocument { ContentHtml = "

after

" }, Stored())); + Assert.False(RemoteEdits.IsEdit(new NoteDocument { Updated = Published.AddSeconds(-1), ContentHtml = "

after

" }, 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 = "

after

" }, Stored())); + Assert.True(RemoteEdits.IsEdit(new NoteDocument { Updated = Published, ContentHtml = "

before

", SpoilerText = "now with a warning" }, Stored())); + Assert.False(RemoteEdits.IsEdit(new NoteDocument { Updated = Published, ContentHtml = "

before

" }, 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 = "

again

" }, Stored(edited))); + Assert.True(RemoteEdits.IsEdit(new NoteDocument { Updated = edited.AddSeconds(1), ContentHtml = "

again

" }, Stored(edited))); + } + } +} diff --git a/PrivaPub/Federation/Inbox/RemoteEdits.cs b/PrivaPub/Federation/Inbox/RemoteEdits.cs index bd1ab33..0326cf9 100644 --- a/PrivaPub/Federation/Inbox/RemoteEdits.cs +++ b/PrivaPub/Federation/Inbox/RemoteEdits.cs @@ -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 Apply(PostEntity post, NoteDocument note, string activityId, ILocalActorService localActors, IObjectRecords records, IQuoteService quotes, CancellationToken token) diff --git a/tools/pasture/lib/interop.sh b/tools/pasture/lib/interop.sh index 106d6b3..6b44699 100644 --- a/tools/pasture/lib/interop.sh +++ b/tools/pasture/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 : 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 diff --git a/tools/pasture/scenarios/gts.sh b/tools/pasture/scenarios/gts.sh index dd0a96d..e2a4459 100644 --- a/tools/pasture/scenarios/gts.sh +++ b/tools/pasture/scenarios/gts.sh @@ -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" diff --git a/tools/pasture/scenarios/mastodon.sh b/tools/pasture/scenarios/mastodon.sh index 4cfc700..f38e64e 100644 --- a/tools/pasture/scenarios/mastodon.sh +++ b/tools/pasture/scenarios/mastodon.sh @@ -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"