perlsky is a Perl 5 implementation of an AT Protocol Personal Data Server.
13
fork

Configure Feed

Select the types of activity you want to include in your feed.

Align sync record proofs with reference PDS

alice 219703b1 8e481884

+397 -14
+38 -9
lib/ATProto/PDS/API/Sync.pm
··· 11 11 use ATProto::PDS::EventStream qw(encode_message_frame); 12 12 use ATProto::PDS::API::Util qw(flatten_params iso8601 pump_event_subscription resolve_did_account subscription_start_seq xrpc_error); 13 13 use ATProto::PDS::Identity qw(service_host); 14 - use ATProto::PDS::Moderation qw(assert_blob_readable assert_record_readable assert_repo_readable); 14 + use ATProto::PDS::Moderation qw(assert_blob_readable assert_repo_readable); 15 15 use ATProto::PDS::Repo::CAR qw(write_car); 16 16 use ATProto::PDS::Repo::CID; 17 17 use ATProto::PDS::Repo::Bytes; 18 + use ATProto::PDS::Repo::MST qw(build_mst); 18 19 19 20 our @EXPORT_OK = qw(register_sync_handlers); 20 21 ··· 70 71 71 72 $registry->register('com.atproto.sync.getRecord', sub ($c, $endpoint) { 72 73 my $account = _readable_repo_by_did($c); 73 - my $record = $c->store->get_record($account->{did}, $c->param('collection'), $c->param('rkey')); 74 - xrpc_error(404, 'RecordNotFound', 'Record was not found') unless $record; 75 - assert_record_readable($c, _record_uri($account->{did}, $record->{collection}, $record->{rkey})); 76 - return _render_repo_car($c, $account->{did}); 74 + return _render_car( 75 + $c, 76 + _record_proof_car( 77 + $c, 78 + $account->{did}, 79 + $c->param('collection') // q(), 80 + $c->param('rkey') // q(), 81 + ), 82 + ); 77 83 }); 78 84 79 85 $registry->register('com.atproto.sync.getBlocks', sub ($c, $endpoint) { ··· 286 292 return _render_car($c, $car); 287 293 } 288 294 295 + sub _record_proof_car ($c, $did, $collection, $rkey) { 296 + my $head = $c->store->get_latest_commit($did); 297 + xrpc_error(404, 'RepoNotFound', 'Repository was not found') unless $head; 298 + my $records = $c->store->all_records_for_did($did); 299 + my %mst_input = map { 300 + $_->{collection} . '/' . $_->{rkey} => ATProto::PDS::Repo::CID->from_string($_->{cid}) 301 + } @$records; 302 + my $mst = build_mst(\%mst_input); 303 + my @blocks = ( 304 + { 305 + cid => ATProto::PDS::Repo::CID->from_string($head->{cid}), 306 + bytes => $head->{commit_bytes}, 307 + }, 308 + @{ $mst->{blocks} }, 309 + ); 310 + 311 + my $record = $c->store->get_record($did, $collection, $rkey); 312 + if ($record) { 313 + push @blocks, { 314 + cid => ATProto::PDS::Repo::CID->from_string($record->{cid}), 315 + bytes => $record->{record_bytes}, 316 + }; 317 + } 318 + 319 + return write_car($blocks[0]{cid}, \@blocks); 320 + } 321 + 289 322 sub _render_car ($c, $car) { 290 323 $c->res->headers->content_type('application/vnd.ipld.car'); 291 324 $c->render(data => $car); 292 325 return; 293 - } 294 - 295 - sub _record_uri ($did, $collection, $rkey) { 296 - return "at://$did/$collection/$rkey"; 297 326 } 298 327 299 328 sub _event_frame ($event) {
+328 -3
script/differential-validate
··· 354 354 return $frame; 355 355 } 356 356 357 + sub frames_until_quiet ($url, $quiet_seconds = 0.25, $timeout = 5) { 358 + my $ua = Mojo::UserAgent->new; 359 + my @frames; 360 + my $error; 361 + my $done = 0; 362 + my $deadline = time + $timeout; 363 + 364 + $ua->websocket($url => sub ($ua, $tx) { 365 + unless ($tx->is_websocket) { 366 + $error = $tx->res->error->{message} // 'websocket handshake failed'; 367 + $done = 1; 368 + Mojo::IOLoop->stop; 369 + return; 370 + } 371 + 372 + my $quiet_timer; 373 + my $reset_quiet = sub { 374 + Mojo::IOLoop->remove($quiet_timer) if defined $quiet_timer; 375 + $quiet_timer = Mojo::IOLoop->timer($quiet_seconds => sub { 376 + $done = 1; 377 + $tx->finish(1000); 378 + }); 379 + }; 380 + $reset_quiet->(); 381 + 382 + my $watchdog; 383 + $watchdog = Mojo::IOLoop->recurring(0.1 => sub { 384 + if (time >= $deadline) { 385 + $error = "timed out waiting for websocket frames at $url"; 386 + Mojo::IOLoop->remove($watchdog); 387 + Mojo::IOLoop->remove($quiet_timer) if defined $quiet_timer; 388 + $done = 1; 389 + $tx->finish(1000); 390 + } 391 + }); 392 + 393 + $tx->on(binary => sub ($tx, $bytes) { 394 + push @frames, decode_frame($bytes); 395 + $reset_quiet->(); 396 + }); 397 + 398 + $tx->on(finish => sub ($tx, $code, $reason = undef) { 399 + Mojo::IOLoop->remove($watchdog) if defined $watchdog; 400 + Mojo::IOLoop->remove($quiet_timer) if defined $quiet_timer; 401 + Mojo::IOLoop->stop if $done || defined $error; 402 + }); 403 + }); 404 + 405 + Mojo::IOLoop->start unless Mojo::IOLoop->is_running; 406 + die "$error\n" if defined $error; 407 + return \@frames; 408 + } 409 + 357 410 sub normalize_commit_frame ($frame, $did) { 358 411 my $header = $frame->{header} || {}; 359 412 my $body = $frame->{body} || {}; ··· 392 445 }; 393 446 } 394 447 448 + sub normalize_bootstrap_frames ($frames, $did, $handle) { 449 + return [ 450 + map { 451 + my $header = $_->{header} || {}; 452 + my $body = $_->{body} || {}; 453 + my $type = $header->{t} // q(); 454 + my %normalized = ( 455 + type => $type, 456 + ); 457 + if ($type eq '#identity') { 458 + %normalized = ( 459 + %normalized, 460 + did_match => (($body->{did} // q()) eq $did) ? 1 : 0, 461 + handle_match => (($body->{handle} // q()) eq $handle) ? 1 : 0, 462 + ); 463 + } elsif ($type eq '#account') { 464 + %normalized = ( 465 + %normalized, 466 + did_match => (($body->{did} // q()) eq $did) ? 1 : 0, 467 + active => $body->{active} ? 1 : 0, 468 + ); 469 + } elsif ($type eq '#commit') { 470 + %normalized = ( 471 + %normalized, 472 + repo_match => (($body->{repo} // q()) eq $did) ? 1 : 0, 473 + ops_count => 0 + @{ $body->{ops} || [] }, 474 + has_blocks => length($body->{blocks} // q()) > 0 ? 1 : 0, 475 + ); 476 + } elsif ($type eq '#sync') { 477 + %normalized = ( 478 + %normalized, 479 + did_match => (($body->{did} // q()) eq $did) ? 1 : 0, 480 + has_blocks => length($body->{blocks} // q()) > 0 ? 1 : 0, 481 + ); 482 + } 483 + \%normalized; 484 + } @$frames 485 + ]; 486 + } 487 + 395 488 sub same_hash ($left, $right) { 396 489 my $json = JSON::PP->new->canonical(1)->allow_nonref(1); 397 490 return $json->encode($left) eq $json->encode($right); ··· 665 758 check(($res->json->{did} // q()) eq $server{$name}{did}, "$name resolveHandle returns the created DID"); 666 759 } 667 760 761 + note('Comparing subscribeRepos bootstrap backfill'); 762 + for my $name (sort keys %server) { 763 + my $frames = frames_until_quiet("$server{$name}{origin}/xrpc/com.atproto.sync.subscribeRepos?cursor=0"); 764 + $server{$name}{bootstrap_firehose} = normalize_bootstrap_frames( 765 + $frames, 766 + $server{$name}{did}, 767 + $server{$name}{handle}, 768 + ); 769 + check(@$frames >= 1, "$name subscribeRepos emits bootstrap frames from cursor=0"); 770 + } 771 + 772 + check( 773 + same_hash($server{reference}{bootstrap_firehose}, $server{perlsky}{bootstrap_firehose}), 774 + 'subscribeRepos bootstrap backfill matches the official reference PDS semantics', 775 + ); 776 + 668 777 note('Comparing crawler notifications'); 669 778 for my $name (sort keys %server) { 670 779 my $state = wait_for_crawler_requests("$name crawler", $server{$name}{crawler_origin}); ··· 693 802 check(($json->{handle} // q()) eq $server{$name}{handle}, "$name getSession returns the created handle"); 694 803 } 695 804 805 + note('Comparing refreshSession token rotation'); 806 + for my $name (sort keys %server) { 807 + my $res = post_empty( 808 + $server{$name}{origin}, 809 + 'com.atproto.server.refreshSession', 810 + auth_header($server{$name}{refresh}), 811 + ); 812 + check($res->is_success, "$name refreshSession succeeds"); 813 + next unless $res->is_success; 814 + my $json = $res->json || {}; 815 + my $old_access = $server{$name}{access}; 816 + my $old_refresh = $server{$name}{refresh}; 817 + $server{$name}{access} = $json->{accessJwt}; 818 + $server{$name}{refresh} = $json->{refreshJwt}; 819 + 820 + my $old_access_res = get_json( 821 + $server{$name}{origin}, 822 + 'com.atproto.server.getSession', 823 + undef, 824 + auth_header($old_access), 825 + ); 826 + my $old_refresh_res = post_empty( 827 + $server{$name}{origin}, 828 + 'com.atproto.server.refreshSession', 829 + auth_header($old_refresh), 830 + ); 831 + my $new_access_res = get_json( 832 + $server{$name}{origin}, 833 + 'com.atproto.server.getSession', 834 + undef, 835 + auth_header($server{$name}{access}), 836 + ); 837 + 838 + $server{$name}{refresh_rotation} = { 839 + has_access_jwt => length($json->{accessJwt} // q()) ? 1 : 0, 840 + has_refresh_jwt => length($json->{refreshJwt} // q()) ? 1 : 0, 841 + access_rotated => (($json->{accessJwt} // q()) ne $old_access) ? 1 : 0, 842 + refresh_rotated => (($json->{refreshJwt} // q()) ne $old_refresh) ? 1 : 0, 843 + new_access_works => $new_access_res->is_success ? 1 : 0, 844 + }; 845 + } 846 + 847 + check( 848 + same_hash($server{reference}{refresh_rotation}, $server{perlsky}{refresh_rotation}), 849 + 'refreshSession token rotation matches the official reference PDS semantics', 850 + ); 851 + 696 852 if ($diff_account_did_method eq 'did:plc') { 697 853 note('Comparing PLC identity semantics'); 698 854 for my $name (sort keys %server) { ··· 790 946 check(defined $json->{cid} && length $json->{cid}, "$name createRecord returns a CID"); 791 947 } 792 948 949 + note('Comparing putRecord update and create semantics'); 950 + for my $name (sort keys %server) { 951 + my $update_res = post_json($server{$name}{origin}, 'com.atproto.repo.putRecord', { 952 + repo => $server{$name}{did}, 953 + collection => 'app.bsky.feed.post', 954 + rkey => 'diffpost', 955 + record => { 956 + %{$record}, 957 + text => "put update validation for $name", 958 + }, 959 + }, auth_header($server{$name}{access})); 960 + check($update_res->is_success, "$name putRecord updates an existing record"); 961 + 962 + my $create_res = post_json($server{$name}{origin}, 'com.atproto.repo.putRecord', { 963 + repo => $server{$name}{did}, 964 + collection => 'app.bsky.feed.post', 965 + rkey => 'put-created', 966 + record => { 967 + %{$record}, 968 + text => "put create validation for $name", 969 + }, 970 + }, auth_header($server{$name}{access})); 971 + check($create_res->is_success, "$name putRecord creates a missing record"); 972 + 973 + my $created_record = get_form($server{$name}{origin}, 'com.atproto.repo.getRecord', { 974 + repo => $server{$name}{did}, 975 + collection => 'app.bsky.feed.post', 976 + rkey => 'put-created', 977 + }); 978 + 979 + $server{$name}{put_record} = { 980 + update_ok => $update_res->is_success ? 1 : 0, 981 + update_uri_expected => (($update_res->json || {})->{uri} // q()) =~ m{/app\.bsky\.feed\.post/diffpost\z} ? 1 : 0, 982 + update_has_cid => defined(($update_res->json || {})->{cid}) && length(($update_res->json || {})->{cid}) ? 1 : 0, 983 + create_ok => $create_res->is_success ? 1 : 0, 984 + create_uri_expected => (($create_res->json || {})->{uri} // q()) =~ m{/app\.bsky\.feed\.post/put-created\z} ? 1 : 0, 985 + create_has_cid => defined(($create_res->json || {})->{cid}) && length(($create_res->json || {})->{cid}) ? 1 : 0, 986 + created_record_reads => $created_record->is_success ? 1 : 0, 987 + created_text_ok => (($created_record->json || {})->{value}{text} // q()) =~ /^put create validation / ? 1 : 0, 988 + }; 989 + } 990 + 991 + check( 992 + same_hash($server{reference}{put_record}, $server{perlsky}{put_record}), 993 + 'putRecord create-or-update semantics match the official reference PDS', 994 + ); 995 + 793 996 note('Comparing moderation takedown behavior'); 794 997 for my $name (sort keys %server) { 795 998 my $res = post_json( ··· 895 1098 list_success => $list_records->is_success ? 1 : 0, 896 1099 }; 897 1100 1101 + my $sync_record = get_form($server{$name}{origin}, 'com.atproto.sync.getRecord', { 1102 + did => $server{$name}{did}, 1103 + collection => $collection, 1104 + rkey => $rkey, 1105 + }); 1106 + my $sync_car = $sync_record->is_success ? read_car($sync_record->body // q()) : undef; 1107 + $server{$name}{record_takedown_sync} = { 1108 + proof_ok => $sync_record->is_success ? 1 : 0, 1109 + proof_is_car => (($sync_record->headers->content_type // q()) =~ m{application/vnd\.ipld\.car}) ? 1 : 0, 1110 + proof_has_record => ( 1111 + $sync_car 1112 + && scalar(grep { $_->{cid}->to_string eq $server{$name}{record_cid} } @{ $sync_car->{blocks} || [] }) 1113 + ) ? 1 : 0, 1114 + }; 1115 + 898 1116 $res = post_json( 899 1117 $server{$name}{origin}, 900 1118 'com.atproto.admin.updateSubjectStatus', ··· 921 1139 'record takedown visibility semantics match the official reference PDS', 922 1140 ); 923 1141 1142 + check( 1143 + same_hash($server{reference}{record_takedown_sync}, $server{perlsky}{record_takedown_sync}), 1144 + 'record takedown sync proof semantics match the official reference PDS', 1145 + ); 1146 + 924 1147 note('Comparing listRecords'); 925 1148 for my $name (sort keys %server) { 926 1149 my $res = get_form($server{$name}{origin}, 'com.atproto.repo.listRecords', { ··· 931 1154 next unless $res->is_success; 932 1155 my $records = $res->json->{records} || []; 933 1156 check(@{$records} >= 1, "$name listRecords returns at least one record"); 934 - check(($records->[0]{value}{text} // q()) eq $record->{text}, "$name listRecords returns the created text"); 1157 + my %by_rkey = map { 1158 + my ($rkey) = (($_->{uri} // q()) =~ m{/([^/?#]+)\z}); 1159 + ($rkey // q()) => $_; 1160 + } @$records; 1161 + $server{$name}{list_records} = { 1162 + count => 0 + @$records, 1163 + has_diffpost => exists $by_rkey{diffpost} ? 1 : 0, 1164 + diffpost_updated => (($by_rkey{diffpost}{value}{text} // q()) =~ /^put update validation /) ? 1 : 0, 1165 + has_put_created => exists $by_rkey{'put-created'} ? 1 : 0, 1166 + put_created_matches => (($by_rkey{'put-created'}{value}{text} // q()) =~ /^put create validation /) ? 1 : 0, 1167 + }; 935 1168 } 936 1169 1170 + check( 1171 + same_hash($server{reference}{list_records}, $server{perlsky}{list_records}), 1172 + 'listRecords returns the same normalized record set as the official reference PDS', 1173 + ); 1174 + 937 1175 note('Comparing getLatestCommit'); 938 1176 for my $name (sort keys %server) { 939 1177 my $res = get_form($server{$name}{origin}, 'com.atproto.sync.getLatestCommit', { did => $server{$name}{did} }); ··· 944 1182 check(defined $json->{rev} && length $json->{rev}, "$name getLatestCommit returns a rev"); 945 1183 } 946 1184 1185 + note('Comparing getHead'); 1186 + for my $name (sort keys %server) { 1187 + my $res = get_form($server{$name}{origin}, 'com.atproto.sync.getHead', { did => $server{$name}{did} }); 1188 + check($res->is_success, "$name getHead succeeds"); 1189 + next unless $res->is_success; 1190 + my $json = $res->json || {}; 1191 + $server{$name}{head} = { 1192 + has_root => defined($json->{root}) && length($json->{root}) ? 1 : 0, 1193 + }; 1194 + } 1195 + 1196 + check( 1197 + same_hash($server{reference}{head}, $server{perlsky}{head}), 1198 + 'getHead matches the official reference PDS semantics', 1199 + ); 1200 + 947 1201 note('Comparing getRepo CAR exports'); 948 1202 for my $name (sort keys %server) { 949 1203 my $res = get_form($server{$name}{origin}, 'com.atproto.sync.getRepo', { did => $server{$name}{did} }); ··· 954 1208 check(length($res->body // q()) > 0, "$name getRepo CAR payload is non-empty"); 955 1209 $server{$name}{repo_snapshot_car} = $res->body; 956 1210 } 1211 + 1212 + note('Comparing getCheckout CAR exports'); 1213 + for my $name (sort keys %server) { 1214 + my $res = get_form($server{$name}{origin}, 'com.atproto.sync.getCheckout', { did => $server{$name}{did} }); 1215 + check($res->is_success, "$name getCheckout succeeds"); 1216 + next unless $res->is_success; 1217 + my $ctype = $res->headers->content_type // q(); 1218 + $server{$name}{checkout} = { 1219 + car_type => $ctype =~ m{application/vnd\.ipld\.car} ? 1 : 0, 1220 + nonempty => length($res->body // q()) > 0 ? 1 : 0, 1221 + }; 1222 + } 1223 + 1224 + check( 1225 + same_hash($server{reference}{checkout}, $server{perlsky}{checkout}), 1226 + 'getCheckout matches the official reference PDS semantics', 1227 + ); 1228 + 1229 + note('Comparing listMissingBlobs empty-state semantics'); 1230 + for my $name (sort keys %server) { 1231 + my $res = get_form($server{$name}{origin}, 'com.atproto.repo.listMissingBlobs', {}, auth_header($server{$name}{access})); 1232 + check($res->is_success, "$name listMissingBlobs succeeds"); 1233 + next unless $res->is_success; 1234 + my $json = $res->json || {}; 1235 + $server{$name}{list_missing_blobs} = { 1236 + blobs_empty => (($json->{blobs} && ref($json->{blobs}) eq 'ARRAY' && !@{$json->{blobs}}) ? 1 : 0), 1237 + has_cursor => exists $json->{cursor} ? 1 : 0, 1238 + }; 1239 + } 1240 + 1241 + check( 1242 + same_hash($server{reference}{list_missing_blobs}, $server{perlsky}{list_missing_blobs}), 1243 + 'listMissingBlobs empty-state semantics match the official reference PDS', 1244 + ); 957 1245 958 1246 note('Comparing importRepo snapshot restore'); 959 1247 for my $name (sort keys %server) { ··· 986 1274 check($res->is_success, "$name listRecords succeeds after importRepo"); 987 1275 next unless $res->is_success; 988 1276 my $records = $res->json->{records} || []; 1277 + my @rkeys = sort map { 1278 + my ($rkey) = (($_->{uri} // q()) =~ m{/([^/?#]+)\z}); 1279 + $rkey // q(); 1280 + } @$records; 989 1281 $server{$name}{import_repo_state} = { 990 - record_count => 0 + @$records, 991 - texts => [ map { $_->{value}{text} } @$records ], 1282 + record_count => 0 + @$records, 1283 + rkeys => \@rkeys, 1284 + restored_diffpost => scalar(grep { $_ eq 'diffpost' } @rkeys) ? 1 : 0, 1285 + restored_put => scalar(grep { $_ eq 'put-created' } @rkeys) ? 1 : 0, 1286 + dropped_import => scalar(grep { $_ eq 'import-diff' } @rkeys) ? 0 : 1, 992 1287 }; 993 1288 } 994 1289 ··· 1044 1339 check( 1045 1340 same_hash($server{reference}{future_cursor}, $server{perlsky}{future_cursor}), 1046 1341 'subscribeRepos agrees on future cursor errors', 1342 + ); 1343 + 1344 + note('Comparing deleteSession revocation behavior'); 1345 + for my $name (sort keys %server) { 1346 + my $session = post_json($server{$name}{origin}, 'com.atproto.server.createSession', { 1347 + identifier => $server{$name}{renamed_handle} || $server{$name}{handle}, 1348 + password => 'hunter22', 1349 + }); 1350 + check($session->is_success, "$name createSession succeeds before deleteSession"); 1351 + next unless $session->is_success; 1352 + my $json = $session->json || {}; 1353 + my $delete = post_empty( 1354 + $server{$name}{origin}, 1355 + 'com.atproto.server.deleteSession', 1356 + auth_header($json->{refreshJwt}), 1357 + ); 1358 + my $get_session = get_json( 1359 + $server{$name}{origin}, 1360 + 'com.atproto.server.getSession', 1361 + undef, 1362 + auth_header($json->{accessJwt}), 1363 + ); 1364 + $server{$name}{delete_session} = { 1365 + delete_ok => $delete->is_success ? 1 : 0, 1366 + }; 1367 + } 1368 + 1369 + check( 1370 + same_hash($server{reference}{delete_session}, $server{perlsky}{delete_session}), 1371 + 'deleteSession revocation behavior matches the official reference PDS', 1047 1372 ); 1048 1373 1049 1374 if ($failed) {
+8 -2
t/moderation.t
··· 21 21 use Test::Mojo; 22 22 use Mojo::URL; 23 23 use ATProto::PDS; 24 + use ATProto::PDS::Repo::CAR qw(read_car); 24 25 25 26 my $root = File::Spec->rel2abs(File::Spec->catdir($Bin, '..')); 26 27 my $tmp = tempdir(CLEANUP => 1); ··· 88 89 did => $did, 89 90 collection => 'app.bsky.feed.post', 90 91 rkey => 'visible-post', 91 - ))->status_is(404) 92 - ->json_is('/error', 'RecordNotFound'); 92 + ))->status_is(200) 93 + ->content_type_like(qr{application/vnd\.ipld\.car}); 94 + my $sync_record_proof = read_car($t->tx->res->body); 95 + ok( 96 + scalar(grep { $_->{cid}->to_string eq $record_cid } @{ $sync_record_proof->{blocks} || [] }), 97 + 'sync.getRecord still exposes a proof CAR for taken-down records', 98 + ); 93 99 94 100 $t->post_ok('/xrpc/com.atproto.admin.updateSubjectStatus' => { 95 101 Authorization => 'Bearer admin-secret',
+23
t/repo-api.t
··· 19 19 20 20 use Test::Mojo; 21 21 use ATProto::PDS; 22 + use ATProto::PDS::Repo::CAR qw(read_car); 22 23 23 24 my $root = File::Spec->rel2abs(File::Spec->catdir($Bin, '..')); 24 25 my $tmp = File::Spec->catdir($root, 'data', 'tmp-tests', 'repo-api'); ··· 71 72 })->status_is(200) 72 73 ->json_is('/uri' => "at://$did/app.bsky.feed.post/first-post") 73 74 ->json_like('/cid' => qr/\Ab/); 75 + my $updated_cid = $t->tx->res->json->{cid}; 74 76 75 77 $t->post_ok('/xrpc/com.atproto.repo.createRecord' => { Authorization => "Bearer $refresh" } => json => { 76 78 repo => $did, ··· 96 98 ->status_is(200) 97 99 ->json_like('/cid' => qr/\Ab/) 98 100 ->json_has('/rev'); 101 + my $latest_commit_cid = $t->tx->res->json->{cid}; 102 + 103 + $t->get_ok("/xrpc/com.atproto.sync.getRecord?did=$did&collection=app.bsky.feed.post&rkey=first-post") 104 + ->status_is(200) 105 + ->content_type_like(qr{application/vnd\.ipld\.car}); 106 + my $record_proof = read_car($t->tx->res->body); 107 + is($record_proof->{roots}[0]->to_string, $latest_commit_cid, 'sync.getRecord roots the latest commit'); 108 + ok( 109 + scalar(grep { $_->{cid}->to_string eq $updated_cid } @{ $record_proof->{blocks} || [] }), 110 + 'sync.getRecord proof includes the current record block', 111 + ); 99 112 100 113 $t->get_ok("/xrpc/com.atproto.sync.getRepoStatus?did=$did") 101 114 ->status_is(200) ··· 129 142 $t->get_ok("/xrpc/com.atproto.repo.getRecord?repo=$did&collection=app.bsky.feed.post&rkey=first-post") 130 143 ->status_is(404) 131 144 ->json_is('/error' => 'RecordNotFound'); 145 + 146 + $t->get_ok("/xrpc/com.atproto.sync.getRecord?did=$did&collection=app.bsky.feed.post&rkey=first-post") 147 + ->status_is(200) 148 + ->content_type_like(qr{application/vnd\.ipld\.car}); 149 + my $missing_record_proof = read_car($t->tx->res->body); 150 + ok(@{ $missing_record_proof->{blocks} || [] } >= 2, 'missing sync proof still includes commit and MST blocks'); 151 + ok( 152 + !scalar(grep { $_->{cid}->to_string eq $updated_cid } @{ $missing_record_proof->{blocks} || [] }), 153 + 'missing sync proof omits the deleted record block', 154 + ); 132 155 133 156 done_testing;