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 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01ELjqpznMFMNrJoJUj6K5p2
This commit is contained in:
1 parent
5f31dbd521
commit
d4e9acdb79
6 files changed
+147
-8
No files matched your search
@@ -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.
|
||||
|
||||
@@ -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)));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
Reference in new issue
Block a user