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"