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.

Harden account email updates and admin views

alice 9a3afbba 353b8348

+373 -22
+4 -8
lib/ATProto/PDS/API/Admin.pm
··· 8 8 use Exporter 'import'; 9 9 use JSON::PP (); 10 10 11 - use ATProto::PDS::API::Helpers qw(account_view find_account invite_code_view require_admin subject_key); 11 + use ATProto::PDS::API::Helpers qw(account_view admin_account_view find_account invite_code_view require_admin subject_key update_account_email); 12 12 use ATProto::PDS::API::Util qw(flatten_params xrpc_error); 13 13 use ATProto::PDS::Auth::Password qw(hash_password); 14 14 use ATProto::PDS::Constants qw(EVENT_TYPE_IDENTITY); ··· 23 23 require_admin($c); 24 24 my $account = $c->store->get_account_by_did($c->param('did') // q()); 25 25 xrpc_error(404, 'AccountNotFound', 'Account was not found') unless $account; 26 - return account_view($account); 26 + return admin_account_view($c->store, $account, entryway => $c->config_value('entryway', 0)); 27 27 }); 28 28 29 29 $registry->register('com.atproto.admin.getAccountInfos', sub ($c, $endpoint) { ··· 32 32 my %accounts_by_did = map { $_->{did} => $_ } @{ $c->store->get_accounts_by_dids(\@dids) }; 33 33 return { 34 34 infos => [ 35 - map { account_view($_) } 35 + map { admin_account_view($c->store, $_, entryway => $c->config_value('entryway', 0)) } 36 36 grep { defined } 37 37 map { $accounts_by_did{$_} } @dids 38 38 ], ··· 180 180 my $body = $c->req->json || {}; 181 181 my $account = find_account($c, $body->{account} // q()); 182 182 xrpc_error(404, 'AccountNotFound', 'Account was not found') unless $account; 183 - $c->store->update_account( 184 - $account->{did}, 185 - email => $body->{email}, 186 - email_confirmed_at => undef, 187 - ); 183 + update_account_email($c, $account->{did}, $body->{email}); 188 184 return {}; 189 185 }); 190 186
+52 -1
lib/ATProto/PDS/API/Helpers.pm
··· 10 10 11 11 use ATProto::PDS::API::Util qw(iso8601 xrpc_error); 12 12 use ATProto::PDS::Auth::Password qw(verify_password); 13 - use ATProto::PDS::Constants qw(TOKEN_AUD_ACCESS); 13 + use ATProto::PDS::Constants qw( 14 + ACTION_TOKEN_EMAIL_CONFIRM 15 + ACTION_TOKEN_EMAIL_UPDATE 16 + TOKEN_AUD_ACCESS 17 + ); 14 18 use ATProto::PDS::Moderation qw(admin_authorization_status subject_key); 15 19 16 20 our @EXPORT_OK = qw( 17 21 account_view 22 + admin_account_view 18 23 find_account 19 24 issue_account_action_token 20 25 invite_code_view 21 26 require_admin 22 27 subject_key 28 + update_account_email 23 29 verify_account_password 24 30 verify_login_password 25 31 ); ··· 94 100 return $token; 95 101 } 96 102 103 + sub update_account_email ($c, $did, $email) { 104 + eval { 105 + $c->store->txn(sub ($dbh) { 106 + $c->store->update_account( 107 + $did, 108 + email => $email, 109 + email_confirmed_at => undef, 110 + ); 111 + $c->store->consume_action_tokens_by_did($did, 112 + purposes => [ACTION_TOKEN_EMAIL_CONFIRM, ACTION_TOKEN_EMAIL_UPDATE], 113 + consumed_at => time, 114 + ); 115 + }); 116 + 1; 117 + } or do { 118 + my $err = $@; 119 + xrpc_error(400, 'InvalidRequest', 'This email address is already in use, please use a different email.') 120 + if !ref($err) && ($err // q()) =~ /UNIQUE constraint failed: accounts\.email/; 121 + die $err; 122 + }; 123 + return $c->store->get_account_by_did($did); 124 + } 125 + 97 126 sub account_view ($account) { 98 127 return { 99 128 did => $account->{did}, ··· 105 134 ($account->{invite_note} ? (inviteNote => $account->{invite_note}) : ()), 106 135 (defined($account->{deactivated_at}) ? (deactivatedAt => iso8601($account->{deactivated_at})) : ()), 107 136 }; 137 + } 138 + 139 + sub admin_account_view ($store, $account, %args) { 140 + my $view = { 141 + did => $account->{did}, 142 + handle => $account->{handle}, 143 + indexedAt => iso8601($account->{created_at}), 144 + ($account->{email} ? (email => $account->{email}) : ()), 145 + (defined($account->{email_confirmed_at}) ? (emailConfirmedAt => iso8601($account->{email_confirmed_at})) : ()), 146 + (defined($account->{deactivated_at}) ? (deactivatedAt => iso8601($account->{deactivated_at})) : ()), 147 + ($account->{invite_note} ? (inviteNote => $account->{invite_note}) : ()), 148 + }; 149 + 150 + unless ($args{entryway}) { 151 + my $invited_by = $store->get_invited_by_for_account($account->{did}); 152 + my @invites = @{ $store->list_invite_codes_for_account($account->{did}) || [] }; 153 + $view->{invitesDisabled} = $account->{invites_disabled} ? JSON::PP::true : JSON::PP::false; 154 + $view->{invitedBy} = invite_code_view($store, $invited_by) if $invited_by; 155 + $view->{invites} = [ map { invite_code_view($store, $_) } @invites ]; 156 + } 157 + 158 + return $view; 108 159 } 109 160 110 161 sub invite_code_view ($store, $row) {
+4 -2
lib/ATProto/PDS/API/Misc.pm
··· 123 123 }); 124 124 125 125 $registry->register('com.atproto.identity.submitPlcOperation', sub ($c, $endpoint) { 126 - my (undef, $account) = require_auth( 126 + my ($claims, $account) = require_auth( 127 127 $c, 128 128 audience => TOKEN_AUD_ACCESS, 129 129 required_permission => { ··· 131 131 attr => '*', 132 132 }, 133 133 ); 134 + _assert_full_non_oauth_access($claims); 134 135 xrpc_error(400, 'InvalidRequest', 'PLC operations are only supported for did:plc accounts') 135 136 unless is_plc_did($account->{did}); 136 137 my $body = $c->req->json || {}; ··· 157 158 }); 158 159 159 160 $registry->register('com.atproto.identity.updateHandle', sub ($c, $endpoint) { 160 - my (undef, $account) = require_auth( 161 + my ($claims, $account) = require_auth( 161 162 $c, 162 163 audience => TOKEN_AUD_ACCESS, 163 164 required_permission => { ··· 165 166 attr => 'handle', 166 167 }, 167 168 ); 169 + _assert_full_non_oauth_access($claims); 168 170 my $body = $c->req->json || {}; 169 171 my $domain = $c->config_value('service_handle_domain', 'localhost'); 170 172 my $handle = normalize_handle($body->{handle}, $domain);
+27 -11
lib/ATProto/PDS/API/Server.pm
··· 8 8 use Exporter 'import'; 9 9 use JSON::PP (); 10 10 11 - use ATProto::PDS::API::Helpers qw(find_account invite_code_view issue_account_action_token require_admin verify_account_password verify_login_password); 11 + use ATProto::PDS::API::Helpers qw(find_account invite_code_view issue_account_action_token require_admin update_account_email verify_account_password verify_login_password); 12 12 use ATProto::PDS::API::Util qw(iso8601 xrpc_error); 13 13 use ATProto::PDS::Auth::OAuth qw( 14 14 oauth_scope_allows ··· 290 290 }); 291 291 292 292 $registry->register('com.atproto.server.listAppPasswords', sub ($c, $endpoint) { 293 - my (undef, $account) = require_auth($c, audience => TOKEN_AUD_ACCESS, disallow_oauth => 1); 293 + my (undef, $account) = require_auth( 294 + $c, 295 + audience => TOKEN_AUD_ACCESS, 296 + required_scope => 'full', 297 + disallow_oauth => 1, 298 + ); 294 299 my $rows = $c->store->list_app_passwords_by_did($account->{did}); 295 300 return { 296 301 passwords => [ ··· 306 311 }); 307 312 308 313 $registry->register('com.atproto.server.revokeAppPassword', sub ($c, $endpoint) { 309 - my (undef, $account) = require_auth($c, audience => TOKEN_AUD_ACCESS, disallow_oauth => 1); 314 + my (undef, $account) = require_auth( 315 + $c, 316 + audience => TOKEN_AUD_ACCESS, 317 + required_scope => 'full', 318 + disallow_oauth => 1, 319 + ); 310 320 my $body = $c->req->json || {}; 311 321 my $name = $body->{name} // q(); 312 322 xrpc_error(400, 'InvalidRequest', 'App password name is required') unless length $name; ··· 424 434 }); 425 435 426 436 $registry->register('com.atproto.server.requestEmailConfirmation', sub ($c, $endpoint) { 427 - my (undef, $account) = require_auth( 437 + my ($claims, $account) = require_auth( 428 438 $c, 429 439 audience => TOKEN_AUD_ACCESS, 430 440 required_permission => { ··· 433 443 action => 'manage', 434 444 }, 435 445 ); 446 + _assert_full_non_oauth_access($claims); 436 447 return {} unless $account->{email}; 437 448 issue_account_action_token( 438 449 $c, ··· 446 457 447 458 $registry->register('com.atproto.server.confirmEmail', sub ($c, $endpoint) { 448 459 if (!$c->config_value('testing_allow_unauthenticated_email_confirm', 0)) { 449 - require_auth( 460 + my ($claims) = require_auth( 450 461 $c, 451 462 audience => TOKEN_AUD_ACCESS, 452 463 required_permission => { ··· 455 466 action => 'manage', 456 467 }, 457 468 ); 469 + _assert_full_non_oauth_access($claims); 458 470 } 459 471 my $body = $c->req->json || {}; 460 472 my $token = _require_action_token($c, ··· 476 488 }); 477 489 478 490 $registry->register('com.atproto.server.requestEmailUpdate', sub ($c, $endpoint) { 479 - my (undef, $account) = require_auth( 491 + my ($claims, $account) = require_auth( 480 492 $c, 481 493 audience => TOKEN_AUD_ACCESS, 482 494 required_permission => { ··· 485 497 action => 'manage', 486 498 }, 487 499 ); 500 + _assert_full_non_oauth_access($claims); 488 501 my $token_required = defined $account->{email_confirmed_at} ? 1 : 0; 489 502 if ($token_required) { 490 503 issue_account_action_token( ··· 519 532 unless ($token->{did} // q()) eq $account->{did}; 520 533 $c->store->consume_action_token($token->{token}); 521 534 } 522 - $c->store->update_account( 523 - $account->{did}, 524 - email => $body->{email}, 525 - email_confirmed_at => undef, 526 - ); 535 + update_account_email($c, $account->{did}, $body->{email}); 527 536 return {}; 528 537 }); 529 538 ··· 848 857 return $scope eq TOKEN_AUD_ACCESS || $scope eq 'app_password' || $scope eq 'app_password_privileged' 849 858 if $required_scope eq 'standard'; 850 859 return 0; 860 + } 861 + 862 + sub _assert_full_non_oauth_access ($claims) { 863 + return 1 if ($claims->{typ} // q()) eq 'oauth_access'; 864 + xrpc_error(400, 'InvalidToken', 'Bad token scope') 865 + unless _scope_allows($claims->{scope}, 'full'); 866 + return 1; 851 867 } 852 868 853 869 sub _service_auth_method_requires_privileged_access ($lxm) {
+2
lib/ATProto/PDS/Store/SQLite.pm
··· 24 24 ); 25 25 use ATProto::PDS::Store::SQLite::ActionTokens qw( 26 26 consume_action_token 27 + consume_action_tokens_by_did 27 28 create_action_token 28 29 get_action_token 29 30 latest_action_token ··· 32 33 create_invite_code 33 34 disable_invite_codes 34 35 get_invite_code 36 + get_invited_by_for_account 35 37 list_invite_code_uses 36 38 list_invite_codes 37 39 list_invite_codes_for_account
+18
lib/ATProto/PDS/Store/SQLite/ActionTokens.pm
··· 9 9 10 10 our @EXPORT_OK = qw( 11 11 consume_action_token 12 + consume_action_tokens_by_did 12 13 create_action_token 13 14 get_action_token 14 15 latest_action_token ··· 54 55 $token, 55 56 ); 56 57 return $self->get_action_token($token); 58 + } 59 + 60 + sub consume_action_tokens_by_did ($self, $did, %args) { 61 + my @where = ('did = ?', 'consumed_at IS NULL'); 62 + my @bind = ($args{consumed_at} // time, $did); 63 + if (my $purposes = $args{purposes}) { 64 + if (ref($purposes) eq 'ARRAY' && @$purposes) { 65 + push @where, 'purpose IN (' . join(', ', ('?') x @$purposes) . ')'; 66 + push @bind, @$purposes; 67 + } 68 + } 69 + $self->dbh->do( 70 + 'UPDATE action_tokens SET consumed_at = ? WHERE ' . join(' AND ', @where), 71 + undef, 72 + @bind, 73 + ); 74 + return; 57 75 } 58 76 59 77 sub latest_action_token ($self, %args) {
+20
lib/ATProto/PDS/Store/SQLite/Invites.pm
··· 11 11 create_invite_code 12 12 disable_invite_codes 13 13 get_invite_code 14 + get_invited_by_for_account 14 15 list_invite_code_uses 15 16 list_invite_codes 16 17 list_invite_codes_for_account ··· 107 108 { Slice => {} }, 108 109 $did, 109 110 ); 111 + } 112 + 113 + sub get_invited_by_for_account ($self, $did) { 114 + my $rows = $self->dbh->selectall_arrayref( 115 + q{ 116 + SELECT invite_codes.*, COUNT(all_uses.code) AS use_count_consumed 117 + FROM invite_code_uses AS used 118 + JOIN invite_codes ON invite_codes.code = used.code 119 + LEFT JOIN invite_code_uses AS all_uses ON all_uses.code = invite_codes.code 120 + WHERE used.used_by = ? 121 + GROUP BY invite_codes.code, invite_codes.for_account, invite_codes.created_by, 122 + invite_codes.use_count, invite_codes.disabled, invite_codes.note, invite_codes.created_at 123 + ORDER BY used.used_at ASC, invite_codes.code ASC 124 + LIMIT 1 125 + }, 126 + { Slice => {} }, 127 + $did, 128 + ); 129 + return $rows && @$rows ? $rows->[0] : undef; 110 130 } 111 131 112 132 sub record_invite_code_use ($self, %args) {
+88
t/admin-account-info-helper.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 Test::More; 9 + 10 + BEGIN { 11 + require lib; 12 + my $root = File::Spec->rel2abs(File::Spec->catdir($Bin, '..')); 13 + lib->import( 14 + File::Spec->catdir($root, 'lib'), 15 + File::Spec->catdir($root, 'local', 'lib', 'perl5'), 16 + File::Spec->catdir($root, 'local', 'lib', 'perl5', $Config::Config{archname}), 17 + ); 18 + } 19 + 20 + use ATProto::PDS::API::Helpers qw(admin_account_view); 21 + use ATProto::PDS::Store::SQLite; 22 + 23 + my $tmp = tempdir(CLEANUP => 1); 24 + my $store = ATProto::PDS::Store::SQLite->new( 25 + path => File::Spec->catfile($tmp, 'perlsky.sqlite'), 26 + )->bootstrap; 27 + 28 + my $creator = $store->create_account( 29 + did => 'did:web:example.test:users:creator', 30 + handle => 'creator.example.test', 31 + email => 'creator@example.test', 32 + created_at => 1_700_000_000, 33 + ); 34 + my $invitee = $store->create_account( 35 + did => 'did:web:example.test:users:invitee', 36 + handle => 'invitee.example.test', 37 + email => 'invitee@example.test', 38 + email_confirmed_at => 1_700_000_100, 39 + invites_disabled => 1, 40 + created_at => 1_700_000_050, 41 + ); 42 + 43 + $store->create_invite_code( 44 + code => 'perlsky-first', 45 + for_account => $invitee->{did}, 46 + created_by => $creator->{did}, 47 + use_count => 2, 48 + created_at => 1_700_000_150, 49 + ); 50 + $store->create_invite_code( 51 + code => 'perlsky-second', 52 + for_account => $invitee->{did}, 53 + created_by => $creator->{did}, 54 + use_count => 1, 55 + created_at => 1_700_000_151, 56 + ); 57 + $store->create_invite_code( 58 + code => 'perlsky-parent', 59 + for_account => $creator->{did}, 60 + created_by => 'admin', 61 + use_count => 1, 62 + created_at => 1_700_000_010, 63 + ); 64 + $store->record_invite_code_use( 65 + code => 'perlsky-parent', 66 + used_by => $invitee->{did}, 67 + used_at => 1_700_000_020, 68 + ); 69 + 70 + my $view = admin_account_view($store, $invitee, entryway => 0); 71 + 72 + is($view->{did}, $invitee->{did}, 'admin account view keeps the DID'); 73 + is($view->{handle}, $invitee->{handle}, 'admin account view keeps the handle'); 74 + is($view->{email}, $invitee->{email}, 'admin account view keeps the email'); 75 + is($view->{indexedAt}, '2023-11-14T22:14:10Z', 'admin account view uses created_at for indexedAt'); 76 + ok($view->{invitesDisabled}, 'admin account view exposes invite disablement'); 77 + is($view->{invitedBy}{code}, 'perlsky-parent', 'admin account view includes the invite that created the account'); 78 + is($view->{invitedBy}{createdBy}, 'admin', 'admin account view includes invitedBy metadata'); 79 + is(scalar @{ $view->{invites} || [] }, 2, 'admin account view includes owned invite codes'); 80 + is($view->{invites}[0]{code}, 'perlsky-second', 'admin account view keeps invite ordering'); 81 + is($view->{invites}[1]{code}, 'perlsky-first', 'admin account view includes all invites'); 82 + 83 + my $entryway_view = admin_account_view($store, $invitee, entryway => 1); 84 + ok(!exists $entryway_view->{invitedBy}, 'entryway mode omits invitedBy'); 85 + ok(!exists $entryway_view->{invites}, 'entryway mode omits invite lists'); 86 + ok(!exists $entryway_view->{invitesDisabled}, 'entryway mode omits invite disablement'); 87 + 88 + done_testing;
+37
t/email-confirmation.t
··· 46 46 })->status_is(200); 47 47 my $alice = $t->tx->res->json; 48 48 49 + $t->post_ok('/xrpc/com.atproto.server.createAppPassword' => { 50 + Authorization => "Bearer $alice->{accessJwt}", 51 + } => json => { 52 + name => 'email-helper', 53 + })->status_is(200) 54 + ->json_like('/password' => qr/\w/); 55 + 56 + my $alice_app_password = $t->tx->res->json->{password}; 57 + 58 + $t->post_ok('/xrpc/com.atproto.server.createSession' => json => { 59 + identifier => 'alice.example.test', 60 + password => $alice_app_password, 61 + })->status_is(200); 62 + 63 + my $alice_app_session = $t->tx->res->json; 64 + 49 65 ok(!$alice->{emailConfirmed}, 'new account email stays unconfirmed when testing auto-confirm is disabled'); 50 66 51 67 $t->post_ok('/xrpc/com.atproto.server.requestEmailUpdate' => { ··· 57 73 Authorization => "Bearer $alice->{accessJwt}", 58 74 } => json => {})->status_is(200); 59 75 76 + $t->post_ok('/xrpc/com.atproto.server.requestEmailConfirmation' => { 77 + Authorization => "Bearer $alice_app_session->{accessJwt}", 78 + } => json => {})->status_is(400) 79 + ->json_is('/error' => 'InvalidToken') 80 + ->json_is('/message' => 'Bad token scope'); 81 + 60 82 my $token = $app->store->latest_action_token( 61 83 did => $alice->{did}, 62 84 purpose => 'email_confirm', 63 85 ); 64 86 ok($token, 'email confirmation token was created'); 87 + 88 + $t->post_ok('/xrpc/com.atproto.server.requestEmailUpdate' => { 89 + Authorization => "Bearer $alice_app_session->{accessJwt}", 90 + } => json => {})->status_is(400) 91 + ->json_is('/error' => 'InvalidToken') 92 + ->json_is('/message' => 'Bad token scope'); 65 93 66 94 $t->post_ok('/xrpc/com.atproto.server.confirmEmail' => json => { 67 95 email => 'ALICE@example.test', ··· 81 109 token => $token->{token}, 82 110 })->status_is(400) 83 111 ->json_is('/error' => 'InvalidEmail'); 112 + 113 + $t->post_ok('/xrpc/com.atproto.server.confirmEmail' => { 114 + Authorization => "Bearer $alice_app_session->{accessJwt}", 115 + } => json => { 116 + email => 'ALICE@example.test', 117 + token => $token->{token}, 118 + })->status_is(400) 119 + ->json_is('/error' => 'InvalidToken') 120 + ->json_is('/message' => 'Bad token scope'); 84 121 85 122 ok( 86 123 !defined $app->store->get_account_by_did($alice->{did})->{email_confirmed_at},
+96
t/email-update-helper.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 Test::More; 9 + 10 + BEGIN { 11 + require lib; 12 + my $root = File::Spec->rel2abs(File::Spec->catdir($Bin, '..')); 13 + lib->import( 14 + File::Spec->catdir($root, 'lib'), 15 + File::Spec->catdir($root, 'local', 'lib', 'perl5'), 16 + File::Spec->catdir($root, 'local', 'lib', 'perl5', $Config::Config{archname}), 17 + ); 18 + } 19 + 20 + use ATProto::PDS::API::Helpers qw(update_account_email); 21 + use ATProto::PDS::Constants qw(ACTION_TOKEN_EMAIL_CONFIRM ACTION_TOKEN_EMAIL_UPDATE); 22 + use ATProto::PDS::Store::SQLite; 23 + 24 + { 25 + package t::EmailUpdateHelper::Controller; 26 + 27 + sub new { 28 + my ($class, %args) = @_; 29 + return bless \%args, $class; 30 + } 31 + 32 + sub store { 33 + my ($self) = @_; 34 + return $self->{store}; 35 + } 36 + } 37 + 38 + my $tmp = tempdir(CLEANUP => 1); 39 + my $store = ATProto::PDS::Store::SQLite->new( 40 + path => File::Spec->catfile($tmp, 'perlsky.sqlite'), 41 + )->bootstrap; 42 + 43 + my $controller = t::EmailUpdateHelper::Controller->new(store => $store); 44 + 45 + my $alice = $store->create_account( 46 + did => 'did:web:example.test:users:alice', 47 + handle => 'alice.example.test', 48 + email => 'alice@example.test', 49 + email_confirmed_at => time, 50 + ); 51 + my $bob = $store->create_account( 52 + did => 'did:web:example.test:users:bob', 53 + handle => 'bob.example.test', 54 + email => 'bob@example.test', 55 + email_confirmed_at => time, 56 + ); 57 + 58 + my $confirm_token = $store->create_action_token( 59 + did => $alice->{did}, 60 + email => $alice->{email}, 61 + purpose => ACTION_TOKEN_EMAIL_CONFIRM, 62 + ); 63 + my $update_token = $store->create_action_token( 64 + did => $alice->{did}, 65 + email => $alice->{email}, 66 + purpose => ACTION_TOKEN_EMAIL_UPDATE, 67 + ); 68 + 69 + my $updated = update_account_email($controller, $alice->{did}, 'Alice.New@Example.test'); 70 + is($updated->{email}, 'alice.new@example.test', 'email updates are normalized through the shared helper'); 71 + ok(!defined $updated->{email_confirmed_at}, 'email updates clear email confirmation'); 72 + ok(defined $store->get_action_token($confirm_token->{token})->{consumed_at}, 'email confirmation tokens are revoked after email change'); 73 + ok(defined $store->get_action_token($update_token->{token})->{consumed_at}, 'email update tokens are revoked after email change'); 74 + 75 + my $fresh_token = $store->create_action_token( 76 + did => $alice->{did}, 77 + email => $updated->{email}, 78 + purpose => ACTION_TOKEN_EMAIL_UPDATE, 79 + ); 80 + 81 + my $error = eval { 82 + update_account_email($controller, $alice->{did}, $bob->{email}); 83 + undef; 84 + }; 85 + my $thrown = $@; 86 + ok(!defined $error, 'duplicate email update does not return a success value'); 87 + is(ref($thrown), 'HASH', 'duplicate email update throws an XRPC-style error'); 88 + is($thrown->{status}, 400, 'duplicate email update returns a client error'); 89 + is($thrown->{error}, 'InvalidRequest', 'duplicate email update uses InvalidRequest'); 90 + is($thrown->{message}, 'This email address is already in use, please use a different email.', 'duplicate email update uses the expected reference-style message'); 91 + 92 + $alice = $store->get_account_by_did($alice->{did}); 93 + is($alice->{email}, 'alice.new@example.test', 'duplicate email update leaves the stored email unchanged'); 94 + ok(!defined $store->get_action_token($fresh_token->{token})->{consumed_at}, 'failed duplicate update does not revoke unrelated outstanding tokens'); 95 + 96 + done_testing;
+16
t/plc-identity.t
··· 171 171 handle => 'alice-renamed.test', 172 172 })->status_is(200); 173 173 174 + $t->post_ok('/xrpc/com.atproto.identity.updateHandle' => { 175 + Authorization => "Bearer $app_password_access", 176 + } => json => { 177 + handle => 'alice-app-password.test', 178 + })->status_is(400) 179 + ->json_is('/error', 'InvalidToken') 180 + ->json_is('/message', 'Bad token scope'); 181 + 174 182 $t->post_ok('/xrpc/com.atproto.identity.requestPlcOperationSignature' => { 175 183 Authorization => "Bearer $app_password_access", 176 184 })->status_is(400) ··· 228 236 ); 229 237 ok(length($signed->{sig} // q()) > 10, 'signed operation contains a signature'); 230 238 like($signed->{prev} // q(), qr/\Ab/, 'signed operation references the prior PLC op by CID'); 239 + 240 + $t->post_ok('/xrpc/com.atproto.identity.submitPlcOperation' => { 241 + Authorization => "Bearer $app_password_access", 242 + } => json => { 243 + operation => $signed, 244 + })->status_is(400) 245 + ->json_is('/error', 'InvalidToken') 246 + ->json_is('/message', 'Bad token scope'); 231 247 232 248 $t->post_ok('/xrpc/com.atproto.identity.submitPlcOperation' => { 233 249 Authorization => "Bearer $access",
+9
t/server-auth.t
··· 160 160 })->status_is(400) 161 161 ->json_is('/error' => 'InvalidToken'); 162 162 163 + $t->get_ok('/xrpc/com.atproto.server.listAppPasswords' => { Authorization => "Bearer $app_session->{accessJwt}" }) 164 + ->status_is(400) 165 + ->json_is('/error' => 'InvalidToken'); 166 + 167 + $t->post_ok('/xrpc/com.atproto.server.revokeAppPassword' => { Authorization => "Bearer $app_session->{accessJwt}" } => json => { 168 + name => 'desktop', 169 + })->status_is(400) 170 + ->json_is('/error' => 'InvalidToken'); 171 + 163 172 $t->get_ok('/xrpc/com.atproto.server.getAccountInviteCodes' => { Authorization => "Bearer $app_session->{accessJwt}" }) 164 173 ->status_is(400) 165 174 ->json_is('/error' => 'InvalidToken');