Mojo-ATProto-OAuth

 view release on metacpan or  search on metacpan

t/session_store_sqlite.t  view on Meta::CPAN

use Test2::V0;
use feature 'signatures';
no warnings 'experimental::signatures';

use Mojo::ATProto::OAuth::SessionStore::SQLite qw//;
use Mojo::Promise;

# Full, realistic hashrefs (as Mojo::ATProto::OAuth itself builds them) -
# unlike the in-memory store, SQLite::SessionStore enforces NOT NULL on
# the fields the contract documents as always-present.
sub auth_request_fixture {
    return {
        state                            => 'state-1',
        account_did                      => 'did:plc:aaaaaaaaaaaaaaaaaaaaaaaa',
        handle                           => 'alice.example',
        host_url                         => 'https://pds.example.com',
        auth_server_url                  => 'https://auth.example.com',
        auth_server_token_endpoint       => 'https://auth.example.com/token',
        auth_server_revocation_endpoint  => 'https://auth.example.com/revoke',
        scopes                           => ['atproto', 'transition:generic'],
        request_uri                      => 'urn:ietf:params:oauth:request_uri:abc',
        pkce_verifier                    => 'verifier-abc',
        dpop_authserver_nonce            => 'nonce-1',
        dpop_private_key_pem             => 'PEM-DATA-1',
        upgrade_session_id               => undef,
        client_state                     => {csrf => 'xyz'},
        extra                            => undef,
    };
}

sub session_fixture {
    return {
        account_did                      => 'did:plc:aaaaaaaaaaaaaaaaaaaaaaaa',
        session_id                       => 'state-1',
        handle                           => 'alice.example',
        host_url                         => 'https://pds.example.com',
        auth_server_url                  => 'https://auth.example.com',
        auth_server_token_endpoint       => 'https://auth.example.com/token',
        auth_server_revocation_endpoint  => 'https://auth.example.com/revoke',
        scopes                           => ['atproto', 'transition:generic'],
        access_token                     => 'tok-1',
        refresh_token                    => 'ref-1',
        dpop_authserver_nonce            => 'nonce-2',
        dpop_host_nonce                  => 'nonce-3',
        dpop_private_key_pem             => 'PEM-DATA-1',
    };
}

subtest 'auth request round-trip' => sub {
    my $store = Mojo::ATProto::OAuth::SessionStore::SQLite->new;
    my $info  = auth_request_fixture();

    $store->save_auth_request($info);
    is($store->get_auth_request('state-1'), $info, 'round-trips unchanged, including client_state and a NULL extra');

    $store->delete_auth_request('state-1');
    like(dies { $store->get_auth_request('state-1') }, qr/no auth request found/, 'deleted row is gone');
};

subtest 'get_auth_request dies with a matching message on a miss' => sub {
    my $store = Mojo::ATProto::OAuth::SessionStore::SQLite->new;
    like(dies { $store->get_auth_request('nonexistent') }, qr/no auth request found/, 'miss dies with the expected message');
    ok(lives { $store->delete_auth_request('nonexistent') }, 'deleting a nonexistent state is a silent no-op');
};

subtest 'session round-trip, keyed on the (account_did, session_id) pair' => sub {
    my $store   = Mojo::ATProto::OAuth::SessionStore::SQLite->new;
    my $session = session_fixture();

    $store->save_session($session);
    is($store->get_session('did:plc:aaaaaaaaaaaaaaaaaaaaaaaa', 'state-1'), $session, 'round-trips unchanged');

    like(dies { $store->get_session('did:plc:aaaaaaaaaaaaaaaaaaaaaaaa', 'sess-2') }, qr/no session found/, 'a different session_id under the same did misses');
    like(dies { $store->get_session('did:plc:bbbbbbbbbbbbbbbbbbbbbbbb', 'state-1') }, qr/no session found/, 'a different account_did under the same session_id misses');

    $store->delete_session('did:plc:aaaaaaaaaaaaaaaaaaaaaaaa', 'state-1');
    like(dies { $store->get_session('did:plc:aaaaaaaaaaaaaaaaaaaaaaaa', 'state-1') }, qr/no session found/, 'deleted row is gone');
};

subtest 'save_session upserts by (account_did, session_id) rather than duplicating' => sub {
    my $store = Mojo::ATProto::OAuth::SessionStore::SQLite->new;
    $store->save_session(session_fixture());

    my $upgraded = session_fixture();
    $upgraded->{access_token} = 'tok-2';
    $upgraded->{scopes}       = ['atproto', 'transition:generic', 'transition:chat.bsky'];
    $store->save_session($upgraded);

    my $fetched = $store->get_session('did:plc:aaaaaaaaaaaaaaaaaaaaaaaa', 'state-1');
    is($fetched->{access_token}, 'tok-2', 'second save overwrote the first, in place');
    is($fetched->{scopes}, ['atproto', 'transition:generic', 'transition:chat.bsky'], 'scopes updated too, not just the row identity');
};

subtest 'the _p methods mirror the sync ones' => sub {
    my $store = Mojo::ATProto::OAuth::SessionStore::SQLite->new;
    my $info  = auth_request_fixture();

    $store->save_auth_request_p($info)->wait;
    my $fetched;
    $store->get_auth_request_p('state-1')->then(sub ($i) { $fetched = $i })->wait;
    is($fetched, $info, 'save_auth_request_p / get_auth_request_p round-trip');

    my $err;
    $store->get_auth_request_p('nonexistent')->catch(sub ($e) { $err = $e })->wait;
    like($err, qr/no auth request found/, 'get_auth_request_p rejects (not dies) on a miss, same message');



( run in 0.735 second using v1.01-cache-2.11-cpan-8dfa8b56332 )