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 admin account coverage from catch-all suite

alice f36ce149 8522d5a5

+325 -270
+5 -6
docs/TEST_AUDIT.md
··· 116 116 Current suite counts by bucket: 117 117 118 118 - `direct reference differential`: `5` 119 - - `audited local regression`: `35` 119 + - `audited local regression`: `36` 120 120 - `local correctness/infrastructure`: `13` 121 121 122 122 | Test file | Bucket | Current note | ··· 125 125 | `t/api-util.t` | audited local regression | helper semantics, cursor validation, service-auth helper behavior | 126 126 | `t/app-routes.t` | local correctness/infrastructure | app route exposure and startup wiring smoke | 127 127 | `t/app.t` | audited local regression | application bootstrap plus malformed-handle rejection and startup hardening | 128 + | `t/admin-account-surfaces.t` | audited local regression | isolated admin account-maintenance coverage for handle/email/password/signing-key/send-email/subject-status behaviors | 128 129 | `t/account-migration-auth.t` | audited local regression | explicit-`did` account creation requires authenticated migration service-auth and preserves remote DID-doc state while starting deactivated | 129 130 | `t/auth-jwt.t` | local correctness/infrastructure | JWT signing and validation behavior | 130 131 | `t/browser-smoke.t` | local correctness/infrastructure | optional browser-driven end-to-end wrapper | ··· 173 174 | `t/store-sqlite.t` | audited local regression | store-level session, invite, label, and repo persistence behavior | 174 175 | `t/temp-endpoints.t` | audited local regression | isolated local coverage for `com.atproto.temp.*` semantics and admin credential revocation behavior | 175 176 | `t/tid-repair.t` | local correctness/infrastructure | TID repair and recovery helpers | 176 - | `t/uncovered-endpoints.t` | audited local regression | intentionally mixed catch-all for admin/temp/sync/local-policy edges that are easy to miss elsewhere; useful coverage, but one of the least reference-pure suites in the tree | 177 + | `t/uncovered-endpoints.t` | audited local regression | now a very small pragmatic safety-net for `describeRepo` correctness and crawler host notification/status edges that still do not have a better thematic home | 177 178 178 179 ### Broad Mixed Suites 179 180 ··· 183 184 Carries real conformance value for `applyWrites`, blob/sync flows, and moderation/label visibility, but it still mixes those with some local product behavior such as label fetch/query smoke and invite/account flows. 184 185 - `t/external-surface.t` 185 186 Carries strong external-surface coverage for repo export, blob access, account-status behavior, and label visibility. It is cleaner after moving discovery-specific checks into `t/discovery-surfaces.t`, but still remains broader than a single-endpoint conformance file. 186 - - `t/import-repo.t` 187 - Is close to a clean conformance suite, but still includes the local `accepting_imports` gate in the same file. 188 187 - `t/uncovered-endpoints.t` 189 - 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. 188 + Exists specifically to stop a few lesser-used local endpoints from falling out of coverage; it is much narrower now that the temp, invite, and admin-account blocks have moved into dedicated suites, but it should still be read as a pragmatic safety net, not as a pure reference-alignment suite. 190 189 191 190 ## What This Audit Does Not Yet Claim 192 191 ··· 209 208 4. decide whether to tighten admin auth to reference semantics or document the bearer shortcut as a permanent extension 210 209 5. keep local testing-only toggles, like the email-confirmation bypass, pinned in focused suites instead of letting broad mixed suites depend on them implicitly 211 210 6. keep narrowing the local `ServiceProxy` surface until every locally answered `app.bsky.*` field is either authoritative or explicitly documented as a local-only extension 212 - 7. keep documenting broad suites like `t/extended-api.t`, `t/external-surface.t`, and `t/import-repo.t` as mixed conformance-plus-product coverage rather than over-claiming that every assertion is a pure reference check 211 + 7. keep documenting broad suites like `t/extended-api.t`, `t/external-surface.t`, and `t/uncovered-endpoints.t` as mixed conformance-plus-product coverage rather than over-claiming that every assertion is a pure reference check 213 212 214 213 ## Practical Reading Of The Current Status 215 214
+320
t/admin-account-surfaces.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 JSON::PP (); 22 + use Mojo::URL; 23 + use Test::Mojo; 24 + use ATProto::PDS; 25 + 26 + my $root = File::Spec->rel2abs(File::Spec->catdir($Bin, '..')); 27 + my $tmp = tempdir(CLEANUP => 1); 28 + 29 + my $app = ATProto::PDS->new( 30 + project_root => $root, 31 + settings => { 32 + base_url => 'http://127.0.0.1:7755', 33 + service_handle_domain => 'example.test', 34 + service_did_method => 'did:web', 35 + jwt_secret => 'admin-account-surfaces-secret', 36 + admin_password => 'admin-secret', 37 + testing_auto_confirm_email => 1, 38 + data_dir => $tmp, 39 + db_path => File::Spec->catfile($tmp, 'perlsky.sqlite'), 40 + }, 41 + ); 42 + 43 + my $t = Test::Mojo->new($app); 44 + my $admin_auth = 'Basic ' . encode_base64('admin:admin-secret', q()); 45 + 46 + $t->post_ok('/xrpc/com.atproto.server.createAccount' => json => { 47 + handle => 'alice.example.test', 48 + email => 'alice@example.test', 49 + password => 'hunter22', 50 + })->status_is(200); 51 + 52 + my $created = $t->tx->res->json; 53 + my $did = $created->{did}; 54 + my $access = $created->{accessJwt}; 55 + 56 + $t->post_ok('/xrpc/com.atproto.server.createAccount' => json => { 57 + handle => 'bob.example.test', 58 + email => 'bob@example.test', 59 + password => 'hunter22', 60 + })->status_is(200); 61 + my $bob = $t->tx->res->json; 62 + 63 + $t->post_ok('/xrpc/com.atproto.server.reserveSigningKey' => json => {}) 64 + ->status_is(200) 65 + ->json_like('/signingKey' => qr/\Adid:key:/); 66 + 67 + my $reserved_signing_key = $t->tx->res->json->{signingKey}; 68 + 69 + $t->post_ok('/xrpc/com.atproto.server.reserveSigningKey' => json => { 70 + did => 'did:plc:reserved-target', 71 + })->status_is(200) 72 + ->json_like('/signingKey' => qr/\Adid:key:/); 73 + 74 + my $reserved = $app->store->get_reserved_signing_key('did:plc:reserved-target'); 75 + ok($reserved && $reserved->{signing_key_did}, 'reserveSigningKey persists a reserved key for an explicit DID'); 76 + is($reserved->{signing_key_did}, $t->tx->res->json->{signingKey}, 'stored reserved key matches the returned signing key'); 77 + 78 + my $before_admin_handle_seq = $app->store->latest_event_seq; 79 + { 80 + no warnings 'redefine'; 81 + local *ATProto::PDS::API::Admin::resolve_handle_to_did = sub { 82 + my ($config, $handle) = @_; 83 + return $handle eq 'alice.external.test' ? $did : undef; 84 + }; 85 + 86 + $t->post_ok('/xrpc/com.atproto.admin.updateAccountHandle' => { 87 + Authorization => $admin_auth, 88 + } => json => { 89 + did => $did, 90 + handle => $bob->{handle}, 91 + })->status_is(400) 92 + ->json_is('/error' => 'InvalidRequest') 93 + ->json_is('/message' => 'Handle already taken: bob.example.test'); 94 + 95 + $t->post_ok('/xrpc/com.atproto.admin.updateAccountHandle' => { 96 + Authorization => $admin_auth, 97 + } => json => { 98 + did => $did, 99 + handle => 'alice.external.test', 100 + })->status_is(200) 101 + ->content_is(q()); 102 + } 103 + 104 + my $handle_event = $app->store->list_events_from($before_admin_handle_seq + 1, limit => 1)->[0]; 105 + is($handle_event->{type}, 'identity', 'admin.updateAccountHandle appends an identity event'); 106 + is($handle_event->{payload}{handle}, 'alice.external.test', 'identity event carries the updated handle'); 107 + 108 + $t->get_ok(Mojo::URL->new('/xrpc/com.atproto.admin.getAccountInfos')->query( 109 + dids => [ $did, 'did:web:example.test:users:missing' ], 110 + ) => { 111 + Authorization => $admin_auth, 112 + })->status_is(200) 113 + ->json_is('/infos/0/did' => $did) 114 + ->json_is('/infos/0/handle' => 'alice.external.test'); 115 + 116 + is(scalar @{ $t->tx->res->json->{infos} || [] }, 1, 'getAccountInfos returns only existing accounts'); 117 + 118 + $t->get_ok(Mojo::URL->new('/xrpc/com.atproto.admin.getAccountInfo')->query( 119 + did => 'did:web:missing.test', 120 + ) => { 121 + Authorization => $admin_auth, 122 + })->status_is(400) 123 + ->json_is('/error' => 'NotFound') 124 + ->json_is('/message' => 'Account not found'); 125 + 126 + $t->get_ok(Mojo::URL->new('/xrpc/com.atproto.admin.searchAccounts')->query( 127 + email => 'ALICE@EXAMPLE.TEST', 128 + ) => { 129 + Authorization => $admin_auth, 130 + })->status_is(200) 131 + ->json_is('/accounts/0/did' => $did); 132 + 133 + $t->post_ok('/xrpc/com.atproto.admin.updateAccountEmail' => { 134 + Authorization => $admin_auth, 135 + } => json => { 136 + account => $did, 137 + email => 'Alice+Admin@Example.Test', 138 + })->status_is(200) 139 + ->content_is(q()); 140 + 141 + my $account = $app->store->get_account_by_did($did); 142 + is($account->{email}, 'alice+admin@example.test', 'admin.updateAccountEmail normalizes email'); 143 + ok(!defined($account->{email_confirmed_at}), 'admin.updateAccountEmail clears email confirmation state'); 144 + 145 + $t->post_ok('/xrpc/com.atproto.admin.updateAccountEmail' => { 146 + Authorization => $admin_auth, 147 + } => json => { 148 + account => 'did:web:missing.test', 149 + email => 'missing@example.test', 150 + })->status_is(400) 151 + ->json_is('/error' => 'InvalidRequest') 152 + ->json_is('/message' => 'Account does not exist: did:web:missing.test'); 153 + 154 + $t->get_ok(Mojo::URL->new('/xrpc/com.atproto.admin.getAccountInfo')->query( 155 + did => $did, 156 + ) => { 157 + Authorization => $admin_auth, 158 + })->status_is(200) 159 + ->json_is('/email' => 'alice+admin@example.test'); 160 + 161 + $t->get_ok(Mojo::URL->new('/xrpc/com.atproto.admin.getSubjectStatus')->query( 162 + did => $did, 163 + ) => { 164 + Authorization => $admin_auth, 165 + })->status_is(200) 166 + ->json_is('/subject/did' => $did) 167 + ->json_is('/subject/$type' => 'com.atproto.admin.defs#repoRef') 168 + ->json_is('/takedown/applied' => JSON::PP::false) 169 + ->json_is('/deactivated/applied' => JSON::PP::false); 170 + 171 + $t->get_ok(Mojo::URL->new('/xrpc/com.atproto.admin.getSubjectStatus')->query( 172 + did => 'did:web:missing.test', 173 + ) => { 174 + Authorization => $admin_auth, 175 + })->status_is(400) 176 + ->json_is('/error' => 'NotFound') 177 + ->json_is('/message' => 'Subject not found'); 178 + 179 + $t->get_ok('/xrpc/com.atproto.admin.getSubjectStatus' => { 180 + Authorization => $admin_auth, 181 + })->status_is(400) 182 + ->json_is('/error' => 'InvalidRequest') 183 + ->json_is('/message' => 'No provided subject'); 184 + 185 + $t->get_ok(Mojo::URL->new('/xrpc/com.atproto.admin.getSubjectStatus')->query( 186 + blob => 'bafkqaaa', 187 + ) => { 188 + Authorization => $admin_auth, 189 + })->status_is(400) 190 + ->json_is('/error' => 'InvalidRequest') 191 + ->json_is('/message' => 'Must provide a did to request blob state'); 192 + 193 + $t->post_ok('/xrpc/com.atproto.admin.sendEmail' => { 194 + Authorization => $admin_auth, 195 + } => json => { 196 + recipientDid => $did, 197 + content => 'hello from perlsky', 198 + })->status_is(200) 199 + ->json_is('/sent' => JSON::PP::true); 200 + 201 + my $outbound = $app->store->dbh->selectrow_hashref( 202 + q{SELECT * FROM outbound_emails WHERE recipient_did = ? ORDER BY id DESC LIMIT 1}, 203 + undef, 204 + $did, 205 + ); 206 + ok($outbound, 'admin.sendEmail logs an outbound email'); 207 + is($outbound->{subject}, 'Message via your PDS', 'admin.sendEmail uses the reference default subject'); 208 + is($outbound->{recipient_email}, 'alice+admin@example.test', 'admin.sendEmail uses the updated normalized email'); 209 + 210 + $t->post_ok('/xrpc/com.atproto.admin.updateAccountSigningKey' => { 211 + Authorization => $admin_auth, 212 + } => json => { 213 + did => $did, 214 + signingKey => $reserved_signing_key, 215 + })->status_is(200) 216 + ->content_is(q()); 217 + 218 + my $signing_event = $app->store->list_events_from($handle_event->{seq} + 1, limit => 1)->[0]; 219 + is($signing_event->{type}, 'identity', 'admin.updateAccountSigningKey appends an identity event'); 220 + is($signing_event->{payload}{handle}, 'alice.external.test', 'signing-key identity event keeps the current handle'); 221 + 222 + $t->post_ok('/xrpc/com.atproto.admin.updateAccountSigningKey' => { 223 + Authorization => $admin_auth, 224 + } => json => { 225 + did => $did, 226 + signingKey => 'did:key:not-a-real-key', 227 + })->status_is(400) 228 + ->json_is('/error' => 'InvalidRequest') 229 + ->json_is('/message' => 'signingKey must be a valid secp256k1 did:key'); 230 + 231 + my $before_missing_delete_seq = $app->store->latest_event_seq; 232 + $t->post_ok('/xrpc/com.atproto.admin.deleteAccount' => { 233 + Authorization => $admin_auth, 234 + } => json => { 235 + did => 'did:web:missing.test', 236 + })->status_is(200) 237 + ->content_is(''); 238 + 239 + my $missing_delete_event = $app->store->list_events_from($before_missing_delete_seq + 1, limit => 1)->[0]; 240 + is($missing_delete_event->{type}, 'account', 'admin.deleteAccount missing DID still appends an account event'); 241 + is($missing_delete_event->{did}, 'did:web:missing.test', 'missing delete event identifies the requested DID'); 242 + ok(!$missing_delete_event->{payload}{active}, 'missing delete event marks the account inactive'); 243 + is($missing_delete_event->{payload}{status}, 'deleted', 'missing delete event reports deleted status'); 244 + 245 + $t->post_ok('/xrpc/com.atproto.admin.sendEmail' => { 246 + Authorization => $admin_auth, 247 + } => json => { 248 + recipientDid => $did, 249 + subject => 'Hello', 250 + content => 'Testing', 251 + })->status_is(200) 252 + ->json_is('/sent' => JSON::PP::true); 253 + 254 + $t->post_ok('/xrpc/com.atproto.server.createAccount' => json => { 255 + handle => 'noemail.example.test', 256 + password => 'hunter22', 257 + })->status_is(200); 258 + 259 + my $noemail_did = $t->tx->res->json->{did}; 260 + 261 + $t->post_ok('/xrpc/com.atproto.admin.sendEmail' => { 262 + Authorization => $admin_auth, 263 + } => json => { 264 + recipientDid => $noemail_did, 265 + subject => 'Hello', 266 + content => 'Testing', 267 + })->status_is(400) 268 + ->json_is('/error' => 'InvalidRequest'); 269 + 270 + $t->post_ok('/xrpc/com.atproto.admin.sendEmail' => { 271 + Authorization => $admin_auth, 272 + } => json => { 273 + recipientDid => 'did:web:example.test:users:missing', 274 + subject => 'Hello', 275 + content => 'Testing', 276 + })->status_is(400) 277 + ->json_is('/error' => 'InvalidRequest') 278 + ->json_is('/message' => 'Recipient not found'); 279 + 280 + $t->post_ok('/xrpc/com.atproto.admin.updateAccountPassword' => { 281 + Authorization => $admin_auth, 282 + } => json => { 283 + did => $did, 284 + password => 'short', 285 + })->status_is(200) 286 + ->content_is(q()); 287 + 288 + $t->post_ok('/xrpc/com.atproto.server.createSession' => json => { 289 + identifier => 'alice.external.test', 290 + password => 'short', 291 + })->status_is(200) 292 + ->json_has('/accessJwt'); 293 + 294 + $t->post_ok('/xrpc/com.atproto.admin.updateAccountPassword' => { 295 + Authorization => $admin_auth, 296 + } => json => { 297 + did => $did, 298 + password => 'new-hunter22', 299 + })->status_is(200) 300 + ->content_is(q()); 301 + 302 + $t->post_ok('/xrpc/com.atproto.admin.updateAccountPassword' => { 303 + Authorization => $admin_auth, 304 + } => json => { 305 + did => 'did:web:missing.test', 306 + password => 'new-hunter22', 307 + })->status_is(200) 308 + ->content_is(q()); 309 + 310 + $t->get_ok('/xrpc/com.atproto.server.getSession' => { 311 + Authorization => "Bearer $access", 312 + })->status_is(401); 313 + 314 + $t->post_ok('/xrpc/com.atproto.server.createSession' => json => { 315 + identifier => 'alice.external.test', 316 + password => 'new-hunter22', 317 + })->status_is(200) 318 + ->json_has('/accessJwt'); 319 + 320 + done_testing;
-264
t/uncovered-endpoints.t
··· 52 52 my $did = $created->{did}; 53 53 my $access = $created->{accessJwt}; 54 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 - my $bob = $t->tx->res->json; 61 - 62 - $t->post_ok('/xrpc/com.atproto.server.reserveSigningKey' => json => {}) 63 - ->status_is(200) 64 - ->json_like('/signingKey' => qr/\Adid:key:/); 65 - 66 - my $reserved_signing_key = $t->tx->res->json->{signingKey}; 67 - 68 - $t->post_ok('/xrpc/com.atproto.server.reserveSigningKey' => json => { 69 - did => 'did:plc:reserved-target', 70 - })->status_is(200) 71 - ->json_like('/signingKey' => qr/\Adid:key:/); 72 - 73 - my $reserved = $app->store->get_reserved_signing_key('did:plc:reserved-target'); 74 - ok($reserved && $reserved->{signing_key_did}, 'reserveSigningKey persists a reserved key for an explicit DID'); 75 - is($reserved->{signing_key_did}, $t->tx->res->json->{signingKey}, 'stored reserved key matches the returned signing key'); 76 - 77 55 $t->post_ok('/xrpc/com.atproto.repo.createRecord' => { 78 56 Authorization => "Bearer $access", 79 57 } => json => { ··· 111 89 ->json_is('/handleIsCorrect' => JSON::PP::false) 112 90 ->json_is('/didDoc/alsoKnownAs/0' => 'at://wrong.example.test'); 113 91 114 - my $before_admin_handle_seq = $app->store->latest_event_seq; 115 - { 116 - no warnings 'redefine'; 117 - local *ATProto::PDS::API::Admin::resolve_handle_to_did = sub { 118 - my ($config, $handle) = @_; 119 - return $handle eq 'alice.external.test' ? $did : undef; 120 - }; 121 - 122 - $t->post_ok('/xrpc/com.atproto.admin.updateAccountHandle' => { 123 - Authorization => $admin_auth, 124 - } => json => { 125 - did => $did, 126 - handle => $bob->{handle}, 127 - })->status_is(400) 128 - ->json_is('/error' => 'InvalidRequest') 129 - ->json_is('/message' => 'Handle already taken: bob.example.test'); 130 - 131 - $t->post_ok('/xrpc/com.atproto.admin.updateAccountHandle' => { 132 - Authorization => $admin_auth, 133 - } => json => { 134 - did => $did, 135 - handle => 'alice.external.test', 136 - })->status_is(200) 137 - ->content_is(q()); 138 - } 139 - 140 - my $handle_event = $app->store->list_events_from($before_admin_handle_seq + 1, limit => 1)->[0]; 141 - is($handle_event->{type}, 'identity', 'admin.updateAccountHandle appends an identity event'); 142 - is($handle_event->{payload}{handle}, 'alice.external.test', 'identity event carries the updated handle'); 143 - 144 - $t->get_ok(Mojo::URL->new('/xrpc/com.atproto.admin.getAccountInfos')->query( 145 - dids => [ $did, 'did:web:example.test:users:missing' ], 146 - ) => { 147 - Authorization => $admin_auth, 148 - })->status_is(200) 149 - ->json_is('/infos/0/did' => $did) 150 - ->json_is('/infos/0/handle' => 'alice.external.test'); 151 - 152 - is(scalar @{ $t->tx->res->json->{infos} || [] }, 1, 'getAccountInfos returns only existing accounts'); 153 - 154 - $t->get_ok(Mojo::URL->new('/xrpc/com.atproto.admin.getAccountInfo')->query( 155 - did => 'did:web:missing.test', 156 - ) => { 157 - Authorization => $admin_auth, 158 - })->status_is(400) 159 - ->json_is('/error' => 'NotFound') 160 - ->json_is('/message' => 'Account not found'); 161 - 162 - $t->get_ok(Mojo::URL->new('/xrpc/com.atproto.admin.searchAccounts')->query( 163 - email => 'ALICE@EXAMPLE.TEST', 164 - ) => { 165 - Authorization => $admin_auth, 166 - })->status_is(200) 167 - ->json_is('/accounts/0/did' => $did); 168 - 169 - $t->post_ok('/xrpc/com.atproto.admin.updateAccountEmail' => { 170 - Authorization => $admin_auth, 171 - } => json => { 172 - account => $did, 173 - email => 'Alice+Admin@Example.Test', 174 - })->status_is(200) 175 - ->content_is(q()); 176 - 177 - $account = $app->store->get_account_by_did($did); 178 - is($account->{email}, 'alice+admin@example.test', 'admin.updateAccountEmail normalizes email'); 179 - ok(!defined($account->{email_confirmed_at}), 'admin.updateAccountEmail clears email confirmation state'); 180 - 181 - $t->post_ok('/xrpc/com.atproto.admin.updateAccountEmail' => { 182 - Authorization => $admin_auth, 183 - } => json => { 184 - account => 'did:web:missing.test', 185 - email => 'missing@example.test', 186 - })->status_is(400) 187 - ->json_is('/error' => 'InvalidRequest') 188 - ->json_is('/message' => 'Account does not exist: did:web:missing.test'); 189 - 190 - $t->get_ok(Mojo::URL->new('/xrpc/com.atproto.admin.getAccountInfo')->query( 191 - did => $did, 192 - ) => { 193 - Authorization => $admin_auth, 194 - })->status_is(200) 195 - ->json_is('/email' => 'alice+admin@example.test'); 196 - 197 - $t->get_ok(Mojo::URL->new('/xrpc/com.atproto.admin.getSubjectStatus')->query( 198 - did => $did, 199 - ) => { 200 - Authorization => $admin_auth, 201 - })->status_is(200) 202 - ->json_is('/subject/did' => $did) 203 - ->json_is('/subject/$type' => 'com.atproto.admin.defs#repoRef') 204 - ->json_is('/takedown/applied' => JSON::PP::false) 205 - ->json_is('/deactivated/applied' => JSON::PP::false); 206 - 207 - $t->get_ok(Mojo::URL->new('/xrpc/com.atproto.admin.getSubjectStatus')->query( 208 - did => 'did:web:missing.test', 209 - ) => { 210 - Authorization => $admin_auth, 211 - })->status_is(400) 212 - ->json_is('/error' => 'NotFound') 213 - ->json_is('/message' => 'Subject not found'); 214 - 215 - $t->get_ok('/xrpc/com.atproto.admin.getSubjectStatus' => { 216 - Authorization => $admin_auth, 217 - })->status_is(400) 218 - ->json_is('/error' => 'InvalidRequest') 219 - ->json_is('/message' => 'No provided subject'); 220 - 221 - $t->get_ok(Mojo::URL->new('/xrpc/com.atproto.admin.getSubjectStatus')->query( 222 - blob => 'bafkqaaa', 223 - ) => { 224 - Authorization => $admin_auth, 225 - })->status_is(400) 226 - ->json_is('/error' => 'InvalidRequest') 227 - ->json_is('/message' => 'Must provide a did to request blob state'); 228 - 229 - $t->post_ok('/xrpc/com.atproto.admin.sendEmail' => { 230 - Authorization => $admin_auth, 231 - } => json => { 232 - recipientDid => $did, 233 - content => 'hello from perlsky', 234 - })->status_is(200) 235 - ->json_is('/sent' => JSON::PP::true); 236 - 237 - my $outbound = $app->store->dbh->selectrow_hashref( 238 - q{SELECT * FROM outbound_emails WHERE recipient_did = ? ORDER BY id DESC LIMIT 1}, 239 - undef, 240 - $did, 241 - ); 242 - ok($outbound, 'admin.sendEmail logs an outbound email'); 243 - is($outbound->{subject}, 'Message via your PDS', 'admin.sendEmail uses the reference default subject'); 244 - is($outbound->{recipient_email}, 'alice+admin@example.test', 'admin.sendEmail uses the updated normalized email'); 245 - 246 - $t->post_ok('/xrpc/com.atproto.admin.updateAccountSigningKey' => { 247 - Authorization => $admin_auth, 248 - } => json => { 249 - did => $did, 250 - signingKey => $reserved_signing_key, 251 - })->status_is(200) 252 - ->content_is(q()); 253 - 254 - my $signing_event = $app->store->list_events_from($handle_event->{seq} + 1, limit => 1)->[0]; 255 - is($signing_event->{type}, 'identity', 'admin.updateAccountSigningKey appends an identity event'); 256 - is($signing_event->{payload}{handle}, 'alice.external.test', 'signing-key identity event keeps the current handle'); 257 - 258 - $t->post_ok('/xrpc/com.atproto.admin.updateAccountSigningKey' => { 259 - Authorization => $admin_auth, 260 - } => json => { 261 - did => $did, 262 - signingKey => 'did:key:not-a-real-key', 263 - })->status_is(400) 264 - ->json_is('/error' => 'InvalidRequest') 265 - ->json_is('/message' => 'signingKey must be a valid secp256k1 did:key'); 266 - 267 - my $before_missing_delete_seq = $app->store->latest_event_seq; 268 - $t->post_ok('/xrpc/com.atproto.admin.deleteAccount' => { 269 - Authorization => $admin_auth, 270 - } => json => { 271 - did => 'did:web:missing.test', 272 - })->status_is(200) 273 - ->content_is(''); 274 - 275 - my $missing_delete_event = $app->store->list_events_from($before_missing_delete_seq + 1, limit => 1)->[0]; 276 - is($missing_delete_event->{type}, 'account', 'admin.deleteAccount missing DID still appends an account event'); 277 - is($missing_delete_event->{did}, 'did:web:missing.test', 'missing delete event identifies the requested DID'); 278 - ok(!$missing_delete_event->{payload}{active}, 'missing delete event marks the account inactive'); 279 - is($missing_delete_event->{payload}{status}, 'deleted', 'missing delete event reports deleted status'); 280 - 281 92 $t->post_ok('/xrpc/com.atproto.sync.notifyOfUpdate' => json => { 282 93 hostname => 'crawler.example.test', 283 94 })->status_is(200) ··· 288 99 })->status_is(200) 289 100 ->json_is('/hostname' => 'crawler.example.test') 290 101 ->json_is('/status' => 'active'); 291 - 292 - $t->post_ok('/xrpc/com.atproto.admin.sendEmail' => { 293 - Authorization => $admin_auth, 294 - } => json => { 295 - recipientDid => $did, 296 - subject => 'Hello', 297 - content => 'Testing', 298 - })->status_is(200) 299 - ->json_is('/sent' => JSON::PP::true); 300 - 301 - $t->post_ok('/xrpc/com.atproto.server.createAccount' => json => { 302 - handle => 'noemail.example.test', 303 - password => 'hunter22', 304 - })->status_is(200); 305 - 306 - my $noemail_did = $t->tx->res->json->{did}; 307 - 308 - $t->post_ok('/xrpc/com.atproto.admin.sendEmail' => { 309 - Authorization => $admin_auth, 310 - } => json => { 311 - recipientDid => $noemail_did, 312 - subject => 'Hello', 313 - content => 'Testing', 314 - })->status_is(400) 315 - ->json_is('/error' => 'InvalidRequest'); 316 - 317 - $t->post_ok('/xrpc/com.atproto.admin.sendEmail' => { 318 - Authorization => $admin_auth, 319 - } => json => { 320 - recipientDid => 'did:web:example.test:users:missing', 321 - subject => 'Hello', 322 - content => 'Testing', 323 - })->status_is(400) 324 - ->json_is('/error' => 'InvalidRequest') 325 - ->json_is('/message' => 'Recipient not found'); 326 - 327 - $t->post_ok('/xrpc/com.atproto.admin.updateAccountPassword' => { 328 - Authorization => $admin_auth, 329 - } => json => { 330 - did => $did, 331 - password => 'short', 332 - })->status_is(200) 333 - ->content_is(q()); 334 - 335 - $t->post_ok('/xrpc/com.atproto.server.createSession' => json => { 336 - identifier => 'alice.external.test', 337 - password => 'short', 338 - })->status_is(200) 339 - ->json_has('/accessJwt'); 340 - 341 - $t->post_ok('/xrpc/com.atproto.admin.updateAccountPassword' => { 342 - Authorization => $admin_auth, 343 - } => json => { 344 - did => $did, 345 - password => 'new-hunter22', 346 - })->status_is(200) 347 - ->content_is(q()); 348 - 349 - $t->post_ok('/xrpc/com.atproto.admin.updateAccountPassword' => { 350 - Authorization => $admin_auth, 351 - } => json => { 352 - did => 'did:web:missing.test', 353 - password => 'new-hunter22', 354 - })->status_is(200) 355 - ->content_is(q()); 356 - 357 - $t->get_ok('/xrpc/com.atproto.server.getSession' => { 358 - Authorization => "Bearer $access", 359 - })->status_is(401); 360 - 361 - $t->post_ok('/xrpc/com.atproto.server.createSession' => json => { 362 - identifier => 'alice.external.test', 363 - password => 'new-hunter22', 364 - })->status_is(200) 365 - ->json_has('/accessJwt'); 366 102 367 103 done_testing;