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.

Split invite admin coverage from catch-all suite

alice 5eeb3d62 7ab4f54b

+236 -172
+3 -2
docs/TEST_AUDIT.md
··· 116 116 Current suite counts by bucket: 117 117 118 118 - `direct reference differential`: `5` 119 - - `audited local regression`: `32` 119 + - `audited local regression`: `33` 120 120 - `local correctness/infrastructure`: `13` 121 121 122 122 | Test file | Bucket | Current note | ··· 144 144 | `t/import-repo.t` | audited local regression | focused `importRepo` snapshot-restore and rollback behavior, now cleaner after splitting the disabled-import policy gate into its own suite | 145 145 | `t/import-repo-policy.t` | audited local regression | local service-policy coverage for the `accepting_imports` gate on `importRepo` | 146 146 | `t/invite-gating.t` | audited local regression | self-service invite flag behavior | 147 + | `t/invite-admin.t` | audited local regression | isolated invite-management coverage for admin listing/disabling and self-service invite-account gating | 147 148 | `t/ipld-canonical.t` | local correctness/infrastructure | canonical IPLD encoding invariants | 148 149 | `t/ipld-codecs.t` | local correctness/infrastructure | DAG-CBOR and codec coverage | 149 150 | `t/labels.t` | audited local regression | label persistence, replay, negation, and cursor behavior | ··· 183 184 - `t/import-repo.t` 184 185 Is close to a clean conformance suite, but still includes the local `accepting_imports` gate in the same file. 185 186 - `t/uncovered-endpoints.t` 186 - Exists specifically to stop lesser-used local endpoints from falling out of coverage; it is a little narrower after moving the self-contained `com.atproto.temp.*` checks into `t/temp-endpoints.t`, but it should still be read as a pragmatic safety net, not as a pure reference-alignment suite. 187 + Exists specifically to stop lesser-used local endpoints from falling out of coverage; it is narrower now that both the self-contained `com.atproto.temp.*` checks and invite-management block have moved into dedicated suites, but it should still be read as a pragmatic safety net, not as a pure reference-alignment suite. 187 188 188 189 ## What This Audit Does Not Yet Claim 189 190
+233
t/invite-admin.t
··· 1 + use v5.34; 2 + use warnings; 3 + 4 + use Config (); 5 + use File::Spec; 6 + use File::Temp qw(tempdir); 7 + use FindBin qw($Bin); 8 + use MIME::Base64 qw(encode_base64); 9 + use Test::More; 10 + 11 + BEGIN { 12 + require lib; 13 + my $root = File::Spec->rel2abs(File::Spec->catdir($Bin, '..')); 14 + lib->import( 15 + File::Spec->catdir($root, 'lib'), 16 + File::Spec->catdir($root, 'local', 'lib', 'perl5'), 17 + File::Spec->catdir($root, 'local', 'lib', 'perl5', $Config::Config{archname}), 18 + ); 19 + } 20 + 21 + use Test::Mojo; 22 + use ATProto::PDS; 23 + 24 + my $root = File::Spec->rel2abs(File::Spec->catdir($Bin, '..')); 25 + my $tmp = tempdir(CLEANUP => 1); 26 + 27 + my $app = ATProto::PDS->new( 28 + project_root => $root, 29 + settings => { 30 + base_url => 'http://127.0.0.1:7755', 31 + service_handle_domain => 'example.test', 32 + service_did_method => 'did:web', 33 + jwt_secret => 'invite-admin-secret', 34 + admin_password => 'admin-secret', 35 + self_service_invite_codes => 1, 36 + testing_auto_confirm_email => 1, 37 + data_dir => $tmp, 38 + db_path => File::Spec->catfile($tmp, 'perlsky.sqlite'), 39 + }, 40 + ); 41 + 42 + my $t = Test::Mojo->new($app); 43 + my $admin_auth = 'Basic ' . encode_base64('admin:admin-secret', q()); 44 + 45 + $t->post_ok('/xrpc/com.atproto.server.createAccount' => json => { 46 + handle => 'alice.example.test', 47 + email => 'alice@example.test', 48 + password => 'hunter22', 49 + })->status_is(200); 50 + 51 + my $alice = $t->tx->res->json; 52 + my $did = $alice->{did}; 53 + my $access = $alice->{accessJwt}; 54 + 55 + $t->post_ok('/xrpc/com.atproto.server.createAccount' => json => { 56 + handle => 'bob.example.test', 57 + email => 'bob@example.test', 58 + password => 'hunter22', 59 + })->status_is(200); 60 + 61 + my $bob = $t->tx->res->json; 62 + 63 + $t->post_ok('/xrpc/com.atproto.server.createInviteCodes' => { 64 + Authorization => $admin_auth, 65 + } => json => { 66 + codeCount => 2, 67 + useCount => 3, 68 + })->status_is(200) 69 + ->json_is('/codes/0/account' => 'admin'); 70 + 71 + my @admin_codes = @{ $t->tx->res->json->{codes}[0]{codes} || [] }; 72 + is(scalar @admin_codes, 2, 'admin createInviteCodes returns the requested number of codes'); 73 + 74 + $app->store->create_invite_code( 75 + code => 'perlsky-audit-used', 76 + for_account => 'admin', 77 + created_by => 'admin', 78 + use_count => 2, 79 + created_at => 4_102_444_700, 80 + ); 81 + $app->store->create_invite_code( 82 + code => 'perlsky-audit-unused', 83 + for_account => 'admin', 84 + created_by => 'admin', 85 + use_count => 1, 86 + created_at => 4_102_444_800, 87 + ); 88 + $app->store->record_invite_code_use( 89 + code => 'perlsky-audit-used', 90 + used_by => $did, 91 + used_at => 4_102_444_900, 92 + ); 93 + $app->store->record_invite_code_use( 94 + code => 'perlsky-audit-used', 95 + used_by => $bob->{did}, 96 + used_at => 4_102_445_000, 97 + ); 98 + 99 + $t->get_ok('/xrpc/com.atproto.admin.getInviteCodes?sort=recent&limit=2' => { 100 + Authorization => $admin_auth, 101 + })->status_is(200) 102 + ->json_is('/codes/0/code' => 'perlsky-audit-unused') 103 + ->json_is('/codes/1/code' => 'perlsky-audit-used') 104 + ->json_has('/cursor'); 105 + 106 + $t->get_ok('/xrpc/com.atproto.admin.getInviteCodes?sort=usage&limit=20' => { 107 + Authorization => $admin_auth, 108 + })->status_is(200) 109 + ->json_is('/codes/0/code' => 'perlsky-audit-used') 110 + ->json_is('/codes/0/available' => 2) 111 + ->json_is('/codes/0/uses/0/usedBy' => $bob->{did}) 112 + ->json_is('/codes/0/uses/1/usedBy' => $did) 113 + ->json_has('/cursor'); 114 + 115 + my $usage_codes = $t->tx->res->json->{codes} || []; 116 + ok( 117 + scalar(grep { ($_->{code} // q()) eq 'perlsky-audit-unused' } @$usage_codes), 118 + 'usage invite-code listing includes the unused seeded code', 119 + ); 120 + 121 + $t->get_ok('/xrpc/com.atproto.admin.getInviteCodes?sort=bogus&limit=2' => { 122 + Authorization => $admin_auth, 123 + })->status_is(400) 124 + ->json_is('/error' => 'InvalidRequest') 125 + ->json_is('/message' => 'unknown sort method: bogus'); 126 + 127 + $t->post_ok('/xrpc/com.atproto.admin.disableInviteCodes' => { 128 + Authorization => $admin_auth, 129 + } => json => { 130 + accounts => ['admin'], 131 + })->status_is(400) 132 + ->json_is('/error' => 'InvalidRequest'); 133 + 134 + $t->post_ok('/xrpc/com.atproto.admin.disableInviteCodes' => { 135 + Authorization => $admin_auth, 136 + } => json => { 137 + codes => [$admin_codes[0]], 138 + })->status_is(200) 139 + ->content_is(q()); 140 + 141 + ok($app->store->get_invite_code($admin_codes[0])->{disabled}, 'disableInviteCodes marks the requested code disabled'); 142 + 143 + $t->post_ok('/xrpc/com.atproto.server.createInviteCodes' => { 144 + Authorization => "Bearer $access", 145 + } => json => { 146 + codeCount => 2, 147 + useCount => 1, 148 + forAccounts => [$did], 149 + })->status_is(200) 150 + ->json_is('/codes/0/account' => $did); 151 + 152 + $t->post_ok('/xrpc/com.atproto.server.createInviteCodes' => { 153 + Authorization => "Bearer $access", 154 + } => json => { 155 + codeCount => 1, 156 + useCount => 1, 157 + forAccounts => ['did:web:example.test:users:other'], 158 + })->status_is(400) 159 + ->json_is('/error' => 'InvalidRequest'); 160 + 161 + $t->post_ok('/xrpc/com.atproto.admin.disableAccountInvites' => { 162 + Authorization => $admin_auth, 163 + } => json => { 164 + account => $did, 165 + note => 'paused for audit', 166 + })->status_is(200) 167 + ->content_is(q()); 168 + 169 + $t->post_ok('/xrpc/com.atproto.admin.disableAccountInvites' => { 170 + Authorization => $admin_auth, 171 + } => json => { 172 + account => 'did:web:missing.test', 173 + note => 'ignored', 174 + })->status_is(200) 175 + ->content_is(q()); 176 + 177 + $t->get_ok(Mojo::URL->new('/xrpc/com.atproto.admin.getAccountInfo')->query( 178 + did => $did, 179 + ) => { 180 + Authorization => $admin_auth, 181 + })->status_is(200) 182 + ->json_is('/invitesDisabled' => JSON::PP::true) 183 + ->json_hasnt('/inviteNote'); 184 + 185 + $t->post_ok('/xrpc/com.atproto.server.createInviteCode' => { 186 + Authorization => "Bearer $access", 187 + } => json => { 188 + useCount => 1, 189 + })->status_is(400) 190 + ->json_is('/error' => 'InvalidRequest'); 191 + 192 + $t->post_ok('/xrpc/com.atproto.admin.enableAccountInvites' => { 193 + Authorization => $admin_auth, 194 + } => json => { 195 + account => $did, 196 + })->status_is(200) 197 + ->content_is(q()); 198 + 199 + $t->post_ok('/xrpc/com.atproto.admin.enableAccountInvites' => { 200 + Authorization => $admin_auth, 201 + } => json => { 202 + account => 'did:web:missing.test', 203 + note => 'ignored', 204 + })->status_is(200) 205 + ->content_is(q()); 206 + 207 + $t->post_ok('/xrpc/com.atproto.server.createInviteCode' => { 208 + Authorization => "Bearer $access", 209 + } => json => { 210 + useCount => 1, 211 + })->status_is(200) 212 + ->json_like('/code' => qr/\Aperlsky-/); 213 + 214 + my $user_code = $t->tx->res->json->{code}; 215 + 216 + $t->post_ok('/xrpc/com.atproto.admin.disableInviteCodes' => { 217 + Authorization => $admin_auth, 218 + } => json => { 219 + codes => [$user_code], 220 + })->status_is(200) 221 + ->content_is(q()); 222 + 223 + my ($disabled_row) = grep { $_->{code} eq $user_code } @{ $app->store->list_invite_codes_for_account($did) || [] }; 224 + ok($disabled_row && $disabled_row->{disabled}, 'disableInviteCodes disables the requested invite code'); 225 + 226 + $t->post_ok('/xrpc/com.atproto.admin.disableInviteCodes' => { 227 + Authorization => $admin_auth, 228 + } => json => { 229 + accounts => ['admin'], 230 + })->status_is(400) 231 + ->json_is('/error' => 'InvalidRequest'); 232 + 233 + done_testing;
-170
t/uncovered-endpoints.t
··· 278 278 ok(!$missing_delete_event->{payload}{active}, 'missing delete event marks the account inactive'); 279 279 is($missing_delete_event->{payload}{status}, 'deleted', 'missing delete event reports deleted status'); 280 280 281 - $t->post_ok('/xrpc/com.atproto.server.createInviteCodes' => { 282 - Authorization => $admin_auth, 283 - } => json => { 284 - codeCount => 2, 285 - useCount => 3, 286 - })->status_is(200) 287 - ->json_is('/codes/0/account' => 'admin'); 288 - 289 - my @admin_codes = @{ $t->tx->res->json->{codes}[0]{codes} || [] }; 290 - is(scalar @admin_codes, 2, 'admin createInviteCodes returns the requested number of codes'); 291 - 292 - $app->store->create_invite_code( 293 - code => 'perlsky-audit-used', 294 - for_account => 'admin', 295 - created_by => 'admin', 296 - use_count => 2, 297 - created_at => 4_102_444_700, 298 - ); 299 - $app->store->create_invite_code( 300 - code => 'perlsky-audit-unused', 301 - for_account => 'admin', 302 - created_by => 'admin', 303 - use_count => 1, 304 - created_at => 4_102_444_800, 305 - ); 306 - $app->store->record_invite_code_use( 307 - code => 'perlsky-audit-used', 308 - used_by => $did, 309 - used_at => 4_102_444_900, 310 - ); 311 - $app->store->record_invite_code_use( 312 - code => 'perlsky-audit-used', 313 - used_by => $bob->{did}, 314 - used_at => 4_102_445_000, 315 - ); 316 - 317 - $t->get_ok('/xrpc/com.atproto.admin.getInviteCodes?sort=recent&limit=2' => { 318 - Authorization => $admin_auth, 319 - })->status_is(200) 320 - ->json_is('/codes/0/code' => 'perlsky-audit-unused') 321 - ->json_is('/codes/1/code' => 'perlsky-audit-used') 322 - ->json_has('/cursor'); 323 - 324 - $t->get_ok('/xrpc/com.atproto.admin.getInviteCodes?sort=usage&limit=20' => { 325 - Authorization => $admin_auth, 326 - })->status_is(200) 327 - ->json_is('/codes/0/code' => 'perlsky-audit-used') 328 - ->json_is('/codes/0/available' => 2) 329 - ->json_is('/codes/0/uses/0/usedBy' => $bob->{did}) 330 - ->json_is('/codes/0/uses/1/usedBy' => $did) 331 - ->json_has('/cursor'); 332 - 333 - my $usage_codes = $t->tx->res->json->{codes} || []; 334 - ok( 335 - scalar(grep { ($_->{code} // q()) eq 'perlsky-audit-unused' } @$usage_codes), 336 - 'usage invite-code listing includes the unused seeded code', 337 - ); 338 - 339 - $t->get_ok('/xrpc/com.atproto.admin.getInviteCodes?sort=bogus&limit=2' => { 340 - Authorization => $admin_auth, 341 - })->status_is(400) 342 - ->json_is('/error' => 'InvalidRequest') 343 - ->json_is('/message' => 'unknown sort method: bogus'); 344 - 345 - $t->post_ok('/xrpc/com.atproto.admin.disableInviteCodes' => { 346 - Authorization => $admin_auth, 347 - } => json => { 348 - accounts => ['admin'], 349 - })->status_is(400) 350 - ->json_is('/error' => 'InvalidRequest'); 351 - 352 - $t->post_ok('/xrpc/com.atproto.admin.disableInviteCodes' => { 353 - Authorization => $admin_auth, 354 - } => json => { 355 - codes => [$admin_codes[0]], 356 - })->status_is(200) 357 - ->content_is(q()); 358 - 359 - ok($app->store->get_invite_code($admin_codes[0])->{disabled}, 'disableInviteCodes marks the requested code disabled'); 360 - 361 - $t->post_ok('/xrpc/com.atproto.server.createInviteCodes' => { 362 - Authorization => "Bearer $access", 363 - } => json => { 364 - codeCount => 2, 365 - useCount => 1, 366 - forAccounts => [$did], 367 - })->status_is(200) 368 - ->json_is('/codes/0/account' => $did); 369 - 370 - $t->post_ok('/xrpc/com.atproto.server.createInviteCodes' => { 371 - Authorization => "Bearer $access", 372 - } => json => { 373 - codeCount => 1, 374 - useCount => 1, 375 - forAccounts => ['did:web:example.test:users:other'], 376 - })->status_is(400) 377 - ->json_is('/error' => 'InvalidRequest'); 378 - 379 - $t->post_ok('/xrpc/com.atproto.admin.disableAccountInvites' => { 380 - Authorization => $admin_auth, 381 - } => json => { 382 - account => $did, 383 - note => 'paused for audit', 384 - })->status_is(200) 385 - ->content_is(q()); 386 - 387 - $t->post_ok('/xrpc/com.atproto.admin.disableAccountInvites' => { 388 - Authorization => $admin_auth, 389 - } => json => { 390 - account => 'did:web:missing.test', 391 - note => 'ignored', 392 - })->status_is(200) 393 - ->content_is(q()); 394 - 395 - $t->get_ok(Mojo::URL->new('/xrpc/com.atproto.admin.getAccountInfo')->query( 396 - did => $did, 397 - ) => { 398 - Authorization => $admin_auth, 399 - })->status_is(200) 400 - ->json_is('/invitesDisabled' => JSON::PP::true) 401 - ->json_hasnt('/inviteNote'); 402 - 403 - $t->post_ok('/xrpc/com.atproto.server.createInviteCode' => { 404 - Authorization => "Bearer $access", 405 - } => json => { 406 - useCount => 1, 407 - })->status_is(400) 408 - ->json_is('/error' => 'InvalidRequest'); 409 - 410 - $t->post_ok('/xrpc/com.atproto.admin.enableAccountInvites' => { 411 - Authorization => $admin_auth, 412 - } => json => { 413 - account => $did, 414 - })->status_is(200) 415 - ->content_is(q()); 416 - 417 - $t->post_ok('/xrpc/com.atproto.admin.enableAccountInvites' => { 418 - Authorization => $admin_auth, 419 - } => json => { 420 - account => 'did:web:missing.test', 421 - note => 'ignored', 422 - })->status_is(200) 423 - ->content_is(q()); 424 - 425 - $t->post_ok('/xrpc/com.atproto.server.createInviteCode' => { 426 - Authorization => "Bearer $access", 427 - } => json => { 428 - useCount => 1, 429 - })->status_is(200) 430 - ->json_like('/code' => qr/\Aperlsky-/); 431 - 432 - my $user_code = $t->tx->res->json->{code}; 433 - 434 - $t->post_ok('/xrpc/com.atproto.admin.disableInviteCodes' => { 435 - Authorization => $admin_auth, 436 - } => json => { 437 - codes => [$user_code], 438 - })->status_is(200) 439 - ->content_is(q()); 440 - 441 - my ($disabled_row) = grep { $_->{code} eq $user_code } @{ $app->store->list_invite_codes_for_account($did) || [] }; 442 - ok($disabled_row && $disabled_row->{disabled}, 'disableInviteCodes disables the requested invite code'); 443 - 444 - $t->post_ok('/xrpc/com.atproto.admin.disableInviteCodes' => { 445 - Authorization => $admin_auth, 446 - } => json => { 447 - accounts => ['admin'], 448 - })->status_is(400) 449 - ->json_is('/error' => 'InvalidRequest'); 450 - 451 281 $t->post_ok('/xrpc/com.atproto.sync.notifyOfUpdate' => json => { 452 282 hostname => 'crawler.example.test', 453 283 })->status_is(200)