Making WordPress.org

Changeset 15258


Ignore:
Timestamp:
10/01/2026 03:53:22 PM (31 hours ago)
Author:
obenland
Message:

FreeScout: Tidy up WPOrgSSO sessions and sidebar lookups

Closes ​https://github.com/WordPress/wordpress.org/pull/984.

Location:
sites/trunk
Files:
6 added
18 edited

Legend:

Unmodified
Added
Removed
  • sites/trunk/.github/unit-tests-suites.yml

    r15218 r15258  
    6161    paths:
    6262      - 'api.wordpress.org/public_html/dotorg/trac/pr/**'
     63
     64  freescout-api:
     65    type: standalone
     66    name: FreeScout API
     67    working-directory: api.wordpress.org/public_html/dotorg/freescout
     68    paths:
     69      - 'api.wordpress.org/public_html/dotorg/freescout/**'
    6370
    6471  slack-trac:
  • sites/trunk/api.wordpress.org/public_html/dotorg/freescout/common.php

    r15251 r15258  
    145145 * Gets the email address of the WordPress.org user a conversation is about.
    146146 *
    147  * Usually that's the sender, but bounces and Slack notifications are about someone else.
     147 * Usually that's the sender, but bounces and Slack notifications are about someone else. Who that is comes from what
     148 * the sender wrote, so it's only used once an agent asks for it, with `related` in the payload.
    148149 *
    149150 * @param object $request Request payload.
    … …  
    151152 */
    152153function get_user_email_for_email( object $request ): string {
     154        $user = ! empty( $request->related ) ? get_related_user( $request ) : false;
     155        if ( ! $user ) {
     156                $user = get_sender_user( $request );
     157        }
     158
     159        return $user ? $user->user_email : (string) ( $request->sender->email ?? '' );
     160}
     161
     162/**
     163 * Gets the sender's WordPress.org user, by any of their addresses.
     164 *
     165 * @param object $request Request payload.
     166 * @return \WP_User|false
     167 */
     168function get_sender_user( object $request ): \WP_User|false {
     169        $sender = $request->sender ?? null;
     170        $email  = (string) ( $sender->email ?? '' );
     171        $user   = $email ? get_user_by( 'email', $email ) : false;
     172
     173        if ( ! $user && ! empty( $sender->emails ) ) {
     174                $user = get_user_from_emails( array_map( 'strval', (array) $sender->emails ) );
     175        }
     176
     177        return $user;
     178}
     179
     180/**
     181 * Gets the WordPress.org user a bounce or Slack notification is about, if that's someone other than the sender.
     182 *
     183 * Goes by the sender's address, the subject, and the body, which the sender writes: it can name anyone.
     184 *
     185 * @param object $request Request payload.
     186 * @return \WP_User|false
     187 */
     188function get_related_user( object $request ): \WP_User|false {
    153189        $subject = (string) ( $request->conversation->subject ?? '' );
    154         $sender  = $request->sender ?? null;
    155         $email   = (string) ( $sender->email ?? '' );
    156         $user    = $email ? get_user_by( 'email', $email ) : false;
    157 
    158         // If this is related to a slack user, fetch their details instead.
     190        $email   = (string) ( $request->sender->email ?? '' );
     191        $sender  = get_sender_user( $request );
     192        $user    = false;
     193
     194        // A Slack notification about a member.
    159195        if (
    160196                false !== stripos( $email, 'slack' ) &&
    … …  
    162198        ) {
    163199                $user = get_user_by( 'slug', $m[1] );
    164         }
    165 
    166         // If the sender has alternative emails listed, check to see if they have a profile.
    167         if ( ! $user && ! empty( $sender->emails ) ) {
    168                 $user = get_user_from_emails( array_map( 'strval', (array) $sender->emails ) );
    169         }
    170 
    171         // Ignore @wordpress.org "users", unless it's literally the only match.
    172         if ( $user && str_ends_with( $user->user_email, '@wordpress.org' ) ) {
    173                 $user = false;
    174         }
    175 
    176         // Is this is a bounce for an email that we have included the username in the subject for?
    177         if ( preg_match( '#Are your plugins ready, (.+?)[?]#i', $subject, $m ) ) {
    178                 $user = get_user_by( 'login', $m[1] ) ?: $user;
    179         }
    180 
    181         if ( ! $user && $email && is_bounce( $request ) ) {
     200        } elseif (
     201                // @wordpress.org "users" send notifications, which may bounce.
     202                ( ! $sender || str_ends_with( $sender->user_email, '@wordpress.org' ) ) &&
     203                $email &&
     204                is_bounce( $request )
     205        ) {
    182206                $user = get_user_from_bounce( $request );
    183207        }
    184208
    185         return $user ? $user->user_email : $email;
     209        // Neither someone at WordPress.org, nor the sender after all.
     210        if ( ! $user || str_ends_with( $user->user_email, '@wordpress.org' ) || ( $sender && $sender->ID === $user->ID ) ) {
     211                return false;
     212        }
     213
     214        return $user;
    186215}
    187216
  • sites/trunk/api.wordpress.org/public_html/dotorg/freescout/plugins-themes.php

    r15251 r15258  
    165165        $html = '<ul class="wporg-sidebar-items">';
    166166
     167        // Reviews are the plugins team's; other mailboxes' conversations can name any plugin.
     168        $show_review = str_starts_with( $mailbox_email, 'plugins' );
     169
    167170        foreach ( $post_ids as $post_id ) {
    168171                $post          = get_post( (int) $post_id );
    … …  
    176179                if ( 'plugin' === $type ) {
    177180                        // Only a review in progress has someone on it; the assignment stays after it's done.
    178                         if ( $post->assigned_reviewer && in_array( $post->post_status, array( 'new', 'pending' ), true ) ) {
     181                        if ( $show_review && $post->assigned_reviewer && in_array( $post->post_status, array( 'new', 'pending' ), true ) ) {
    179182                                $reviewer_user = get_user_by( 'id', (int) $post->assigned_reviewer );
    180183                                if ( $reviewer_user ) {
    … …  
    186189                        $last_modified = $post->last_updated ? $post->last_updated : $last_modified;
    187190
    188                         // Get the ZIPs attached, link to the latest for pending/new.
     191                        // Get the ZIPs attached, link to the latest for pending/new. Unreleased, so only for reviews.
    189192                        if ( in_array( $post->post_status, array( 'new', 'pending' ), true ) ) {
    190                                 $attachments   = get_posts(
     193                                $attachments   = $show_review ? get_posts(
    191194                                        array(
    192195                                                'post_parent'    => $post->ID,
    … …  
    196199                                                'posts_per_page' => 1,
    197200                                        )
    198                                 );
     201                                ) : array();
    199202                                $download_link = $attachments ? (string) wp_get_attachment_url( $attachments[0]->ID ) : '';
    200203                        }
    … …  
    203206                        if (
    204207                                $download_link &&
    205                                 str_starts_with( $mailbox_email, 'plugins' ) &&
     208                                $show_review &&
    206209                                class_exists( '\WordPressdotorg\Plugin_Directory\API\Routes\Plugin_Review' )
    207210                        ) {
    … …  
    223226                                $status = ucwords( $post->post_status );
    224227                                // This is not perfect, but close enough.
    225                                 if ( $post->_close_reason ) {
     228                                if ( $show_review && $post->_close_reason ) {
    226229                                        $status .= ': ' . ucwords( str_replace( '-', ' ', $post->_close_reason ) );
    227230                                }
  • sites/trunk/api.wordpress.org/public_html/dotorg/freescout/profile.php

    r15251 r15258  
    2828        $sender_email = (string) ( $request->sender->email ?? '' );
    2929        $email        = get_user_email_for_email( $request );
     30        $related      = ! empty( $request->related );
     31        $slack_email  = preg_match( '/(\S+@chat.wordpress.org)/i', (string) ( $request->conversation->subject ?? '' ), $m ) ? $m[1] : '';
    3032
    3133        if ( $email ) {
    … …  
    7173        }
    7274
    73         // If this is related to a slack user, include the details of the slack account.
    74         if ( $user || preg_match( '/(\S+@chat.wordpress.org)/i', (string) ( $request->conversation->subject ?? '' ), $m ) ) {
     75        // If this is related to a slack user, include the details of the slack account; one the subject names only on request.
     76        if ( $user || ( $related && $slack_email ) ) {
    7577                // Someone can have several Slack accounts over the years; active ones first.
    7678                if ( $user ) {
    … …  
    8082                                $wpdb->prepare(
    8183                                        'SELECT * FROM slack_users WHERE profiledata LIKE %s ORDER BY deactivated ASC',
    82                                         '%' . $wpdb->esc_like( '"email":"' . $m[1] . '"' ) . '%'
     84                                        '%' . $wpdb->esc_like( '"email":"' . $slack_email . '"' ) . '%'
    8385                                )
    8486                        );
    … …  
    8890        }
    8991
    90         return $html;
     92        // The sender wrote whatever names someone else, so an agent decides whether it's worth a look; WPOrgSidebar asks.
     93        if ( $related ) {
     94                $notice = '<p class="wporg-sidebar-meta">Showing the account this is about, not the sender’s. <a href="#" class="wporg-sidebar-show-sender">Show the sender</a></p>';
     95        } elseif ( get_related_user( $request ) || ( ! $user && $slack_email ) ) {
     96                $notice = '<p class="wporg-sidebar-meta">This may be about someone else’s account, like a bounce. <a href="#" class="wporg-sidebar-show-related">Show it</a></p>';
     97        } else {
     98                $notice = '';
     99        }
     100
     101        return $notice . $html;
    91102}
    92103
  • sites/trunk/freescout.wordpress.net/Modules/WPOrgSSO/Http/Controllers/SsoController.php

    r15246 r15258  
    183183                $details = $wporg_user->to_array();
    184184
    185                 // Private on WordPress.org; the form fills it in when the user is created.
     185                // Private on WordPress.org, and any account can be looked up; the form fills the email in when the user is created.
    186186                if ( ! $request->user()->isAdmin() ) {
    187                         unset( $details['email'] );
     187                        unset( $details['email'], $details['two_factor'], $details['blocked'] );
    188188                }
    189189
  • sites/trunk/freescout.wordpress.net/Modules/WPOrgSSO/Http/Middleware/RequireWordPressOrgLogin.php

    r15252 r15258  
    3636         * @var int
    3737         */
    38         private const RECHECK_SECONDS = 3600;
     38        private const RECHECK_SECONDS = 3600; // 1 hour.
     39
     40        /**
     41         * How long sessions from before WordPress.org was enforced go on, in seconds.
     42         *
     43         * @var int
     44         */
     45        private const CUTOVER_SECONDS = 86400; // 1 day.
    3946
    4047        /**
    … …  
    171178         */
    172179        private static function enforce_login( Request $request, ?User $user, string $action ): ?Response {
    173                 if ( $user instanceof User && ! self::may_stay_logged_in( $user, $request ) ) {
     180                // On the first enforced request, before any session can be from while this module was off.
     181                $enforced_since = WPOrgSSOServiceProvider::enforced_since();
     182
     183                if ( $user instanceof User && ! self::may_stay_logged_in( $user, $request, $enforced_since ) ) {
    174184                        \Auth::logout();
    175185                        $request->session()->invalidate();
    … …  
    226236         * Whether a logged-in user may stay logged in.
    227237         *
    228          * @param User    $user    Logged-in user.
    229          * @param Request $request Request.
     238         * @param User    $user           Logged-in user.
     239         * @param Request $request        Request.
     240         * @param int     $enforced_since When WordPress.org was first enforced, as a Unix timestamp.
    230241         * @return bool
    231242         */
    232         private static function may_stay_logged_in( User $user, Request $request ): bool {
     243        private static function may_stay_logged_in( User $user, Request $request, int $enforced_since ): bool {
    233244                $session  = $request->session();
    234245                $username = (string) $session->get( WPOrgSSOServiceProvider::SESSION_USERNAME, '' );
    … …  
    249260
    250261                /*
    251                  * From before WordPress.org was enforced: going on keeps whoever switched it on logged in, so they can connect
    252                  * the accounts. Manage » System » Tools logs everyone out, for a clean switch.
     262                 * From before WordPress.org was enforced: going on for a day keeps whoever switched it on logged in, so they can
     263                 * connect the accounts. Later, an unmarked session is from while this module was off, and can't be trusted.
    253264                 */
    254                 return true;
     265                return time() - $enforced_since < self::CUTOVER_SECONDS;
    255266        }
    256267
  • sites/trunk/freescout.wordpress.net/Modules/WPOrgSSO/Providers/WPOrgSSOServiceProvider.php

    r15252 r15258  
    9494
    9595        /**
     96         * Option of when WordPress.org was first enforced, as a Unix timestamp.
     97         *
     98         * @var string
     99         */
     100        public const OPTION_ENFORCED_SINCE = 'wporgsso.enforced_since';
     101
     102        /**
    96103         * Registers the module's dependencies.
    97104         *
    … …  
    99106         */
    100107        public function register(): void {
    101                 require_once __DIR__ . '/../vendor/autoload.php';
     108                $autoload = __DIR__ . '/../vendor/autoload.php';
     109
     110                // Core switches off a module with a missing file, and WordPress.org logins with it; this keeps them enforced.
     111                if ( ! is_readable( $autoload ) ) {
     112                        \Log::critical( '[WPOrgSSO] vendor/autoload.php is missing; WordPress.org logins fail until it is restored.' );
     113
     114                        return;
     115                }
     116
     117                require_once $autoload;
    102118        }
    103119
    … …  
    148164
    149165                return true;
     166        }
     167
     168        /**
     169         * When WordPress.org was first enforced; the first request that enforces it records it.
     170         *
     171         * It stays when enforcement is switched off, so sessions from while it was off end when it's back.
     172         *
     173         * @return int Unix timestamp.
     174         */
     175        public static function enforced_since(): int {
     176                // It never changes once set; a cleared cache costs one query, and the option keeps the time.
     177                return (int) \Cache::rememberForever(
     178                        self::OPTION_ENFORCED_SINCE,
     179                        static function (): int {
     180                                // Not core's option cache, which outlives a request in tests: a stale default would move the cutover.
     181                                $since = (int) \Option::get( self::OPTION_ENFORCED_SINCE, 0, true, false );
     182                                if ( ! $since ) {
     183                                        $since = time();
     184                                        \Option::set( self::OPTION_ENFORCED_SINCE, $since );
     185                                }
     186
     187                                return $since;
     188                        }
     189                );
    150190        }
    151191
    … …  
    288328                 * Only an administrator's password login in break-glass mode may stay; the middleware ends the session of any
    289329                 * other login without WordPress.org, like one from a reset link or a remember-me cookie, on its next request.
    290                  * Sessions from before WordPress.org was enforced go on, so whoever switches it on isn't logged out.
     330                 * Sessions from before WordPress.org was enforced go on for a day, so whoever switches it on isn't logged out.
    291331                 */
    292332                \Event::listen(
  • sites/trunk/freescout.wordpress.net/Modules/WPOrgSSO/Public/js/users.js

    r15246 r15258  
    147147                                                notes.push( strings.blocked );
    148148                                        }
    149                                         if ( ! user.two_factor ) {
     149                                        // Only administrators get the account's status, too.
     150                                        if ( false === user.two_factor ) {
    150151                                                notes.push( strings.no_two_factor );
    151152                                        }
  • sites/trunk/freescout.wordpress.net/Modules/WPOrgSSO/Services/UserSync.php

    r15246 r15258  
    1919 */
    2020final class UserSync {
     21
     22        /**
     23         * Hosts avatars are downloaded from; account.php's avatars are Gravatar's.
     24         *
     25         * @var string[]
     26         */
     27        private const AVATAR_HOSTS = array( 'gravatar.com', 'www.gravatar.com', 'secure.gravatar.com', '0.gravatar.com', '1.gravatar.com', '2.gravatar.com' );
    2128
    2229        /**
    … …  
    6269         * @return void
    6370         *
    64          * @throws RuntimeException If the avatar can't be downloaded or saved.
     71         * @throws RuntimeException If the avatar isn't Gravatar's, or can't be downloaded or saved.
    6572         */
    6673        public static function sync_avatar( User $user, string $avatar_url, Client $client ): void {
    … …  
    6875                if ( ! $account ) {
    6976                        return;
     77                }
     78
     79                // The app server fetches it, so it must not reach anything internal.
     80                if ( 'https' !== parse_url( $avatar_url, PHP_URL_SCHEME ) || ! in_array( parse_url( $avatar_url, PHP_URL_HOST ), self::AVATAR_HOSTS, true ) ) {
     81                        throw new RuntimeException( 'Not a Gravatar URL: ' . $avatar_url );
    7082                }
    7183
  • sites/trunk/freescout.wordpress.net/Modules/WPOrgSSO/tests/LoginTest.php

    r15252 r15258  
    309309
    310310        /**
     311         * A day after WordPress.org was enforced, a session without its marks is from while the module was off, and ends.
     312         *
     313         * @return void
     314         */
     315        public function test_logs_out_unmarked_sessions_after_the_cutover(): void {
     316                \Option::set( WPOrgSSOServiceProvider::OPTION_ENFORCED_SINCE, time() - 86400 );
     317
     318                $this->actingAs( $this->user )->get( route( 'dashboard' ) )->assertRedirect( route( 'login' ) );
     319                $this->assertGuest();
     320        }
     321
     322        /**
    311323         * A login without WordPress.org once it's enforced ends on the next request.
    312324         *
  • sites/trunk/freescout.wordpress.net/Modules/WPOrgSSO/tests/SsoTestCase.php

    r15246 r15258  
    8383                );
    8484
    85                 $this->avatars = array( 'https://avatars.test/rita.png' => self::image( 255, 0, 0 ) );
     85                $this->avatars = array( 'https://secure.gravatar.com/avatar/rita?s=256&d=mm' => self::image( 255, 0, 0 ) );
    8686
    8787                $this->accounts = array(
    … …  
    9292                                'last_name'    => 'Reviewer',
    9393                                'email'        => 'rita@example.org',
    94                                 'avatar_url'   => 'https://avatars.test/rita.png',
     94                                'avatar_url'   => 'https://secure.gravatar.com/avatar/rita?s=256&d=mm',
    9595                                'two_factor'   => true,
    9696                                'blocked'      => false,
    … …  
    110110
    111111        /**
    112          * Answers requests to account.php and the avatar host.
     112         * Answers requests to account.php and for avatars.
    113113         *
    114114         * @param \Psr\Http\Message\RequestInterface $request Request.
    … …  
    116116         */
    117117        public function answer_api( \Psr\Http\Message\RequestInterface $request ): \GuzzleHttp\Promise\PromiseInterface {
    118                 if ( 'avatars.test' === $request->getUri()->getHost() ) {
    119                         $avatar   = $this->avatars[ (string) $request->getUri() ] ?? null;
     118                $avatar = $this->avatars[ (string) $request->getUri() ] ?? null;
     119                if ( $avatar || 'secure.gravatar.com' === $request->getUri()->getHost() ) {
    120120                        $response = $avatar ? new Response( 200, array( 'Content-Type' => 'image/png' ), $avatar ) : new Response( 404 );
    121121
  • sites/trunk/freescout.wordpress.net/Modules/WPOrgSSO/tests/UserSyncTest.php

    r15246 r15258  
    9595                $hash = Account::for_user( (int) $this->user->id )->avatar_hash;
    9696
    97                 $this->avatars['https://avatars.test/rita.png'] = self::image( 0, 0, 255 );
     97                $this->avatars['https://secure.gravatar.com/avatar/rita?s=256&d=mm'] = self::image( 0, 0, 255 );
    9898                $this->sync();
    9999
    … …  
    139139         */
    140140        public function test_unreadable_avatar_is_logged_not_thrown(): void {
    141                 $this->avatars['https://avatars.test/rita.png'] = 'not an image';
     141                $this->avatars['https://secure.gravatar.com/avatar/rita?s=256&d=mm'] = 'not an image';
     142
     143                $this->sync();
     144
     145                $this->assertSame( 'Rita', $this->user->refresh()->first_name );
     146                $this->assertEmpty( $this->user->photo_url );
     147        }
     148
     149        /**
     150         * Only Gravatar's avatars are downloaded, as the app server fetches them.
     151         *
     152         * @return void
     153         */
     154        public function test_ignores_avatar_off_gravatar(): void {
     155                $this->accounts['rita']['avatar_url']                 = 'https://169.254.169.254/avatar.png';
     156                $this->avatars['https://169.254.169.254/avatar.png'] = self::image( 0, 255, 0 );
    142157
    143158                $this->sync();
  • sites/trunk/freescout.wordpress.net/Modules/WPOrgSSO/tests/UsersTest.php

    r15246 r15258  
    139139
    140140        /**
    141          * Agents who may manage users look up accounts too, but only administrators see the private email address.
    142          *
    143          * @return void
    144          */
    145         public function test_lookup_hides_email_from_non_administrators(): void {
     141         * Agents who may manage users look up accounts too, but only administrators see the email address and status.
     142         *
     143         * @return void
     144         */
     145        public function test_lookup_hides_email_and_status_from_non_administrators(): void {
    146146                $manager              = $this->create_user( User::ROLE_USER );
    147147                $manager->permissions = array( User::PERM_EDIT_USERS => true );
    … …  
    153153                $this->assertSame( 'rita', $data['user']['username'] );
    154154                $this->assertArrayNotHasKey( 'email', $data['user'] );
     155                $this->assertArrayNotHasKey( 'two_factor', $data['user'] );
     156                $this->assertArrayNotHasKey( 'blocked', $data['user'] );
    155157        }
    156158
  • sites/trunk/freescout.wordpress.net/Modules/WPOrgSidebar/Http/Controllers/PanelController.php

    r15251 r15258  
    5454                }
    5555
     56                $payload = ConversationPayload::build( $conversation );
     57
     58                // The account a bounce or Slack notification names, instead of the sender's, once the agent asks for it.
     59                if ( request()->query( 'related' ) ) {
     60                        $payload['related'] = true;
     61                }
     62
    5663                try {
    57                         $response = Client::from_config( self::TIMEOUT )->post(
    58                                 (string) $panels[ $panel ]['endpoint'],
    59                                 ConversationPayload::build( $conversation )
    60                         );
     64                        $response = Client::from_config( self::TIMEOUT )->post( (string) $panels[ $panel ]['endpoint'], $payload );
    6165                } catch ( \Throwable $e ) {
    6266                        \Log::error( '[WPOrgSidebar] Could not load panel ' . $panel . ': ' . $e->getMessage() );
  • sites/trunk/freescout.wordpress.net/Modules/WPOrgSidebar/Public/js/sidebar.js

    r15251 r15258  
    3434                $( '.wporg-sidebar' ).on( 'shown.bs.collapse', fitLayout );
    3535
    36                 $( '.wporg-sidebar-panel[data-url]' ).each( function () {
    37                         const $panel = $( this );
     36                const $panels = $( '.wporg-sidebar-panel[data-url]' );
    3837
    39                         $.getJSON( $panel.data( 'url' ) )
     38                $panels.each( function () {
     39                        loadPanel( $( this ), false );
     40                } );
     41
     42                // Bounces and Slack notifications name someone else's account, which the profile panel offers to switch to, and back.
     43                $( '.wporg-sidebar' ).on(
     44                        'click',
     45                        '.wporg-sidebar-show-related, .wporg-sidebar-show-sender',
     46                        function ( event ) {
     47                                const related = $( this ).hasClass(
     48                                        'wporg-sidebar-show-related'
     49                                );
     50
     51                                event.preventDefault();
     52                                $panels.each( function () {
     53                                        loadPanel( $( this ), related );
     54                                } );
     55                        }
     56                );
     57
     58                /**
     59                 * Loads a panel, hiding it if it has nothing to show.
     60                 *
     61                 * @param {jQuery}  $panel  Panel.
     62                 * @param {boolean} related Whether it's about the account a bounce or Slack notification names, not the sender's.
     63                 */
     64                function loadPanel( $panel, related ) {
     65                        const $block = $panel.closest( '.conv-sidebar-block' );
     66                        const previous = $panel.data( 'request' );
     67
     68                        // A late answer to an earlier load would show the other person.
     69                        if ( previous ) {
     70                                previous.abort();
     71                        }
     72
     73                        const request = $.getJSON(
     74                                $panel.data( 'url' ),
     75                                related ? { related: 1 } : {}
     76                        );
     77                        $panel.data( 'request', request );
     78
     79                        request
    4080                                .done( function ( response ) {
    4181                                        if ( response && response.html ) {
    … …  
    4383                                                $panel.html( response.html );
    4484                                                shortenLists( $panel );
    45                                                 fitLayout();
     85                                                $block.show();
    4686                                        } else {
    47                                                 $panel.closest( '.conv-sidebar-block' ).remove();
     87                                                // Not removed: the other account may have something to show.
     88                                                $block.hide();
    4889                                        }
     90                                        fitLayout();
    4991                                } )
    50                                 .fail( function () {
     92                                .fail( function ( xhr, textStatus ) {
     93                                        if ( 'abort' === textStatus ) {
     94                                                return;
     95                                        }
     96
    5197                                        $panel.html(
    5298                                                $( '<p class="wporg-sidebar-empty">' ).text(
    … …  
    54100                                                )
    55101                                        );
     102                                        $block.show();
    56103                                } );
    57                 } );
     104                }
    58105
    59106                /**
  • sites/trunk/freescout.wordpress.net/Modules/WPOrgSidebar/Services/Client.php

    r15239 r15258  
    4848
    4949        /**
     50         * Guzzle handler, for tests to answer requests.
     51         *
     52         * @var callable|null
     53         */
     54        private $handler;
     55
     56        /**
    5057         * Constructor.
    5158         *
    52          * @param string $base_url Base URL of the endpoints.
    53          * @param string $secret   Shared signing secret.
    54          * @param int    $timeout  Request timeout in seconds.
     59         * @param string        $base_url Base URL of the endpoints.
     60         * @param string        $secret   Shared signing secret.
     61         * @param int           $timeout  Request timeout in seconds.
     62         * @param callable|null $handler  Guzzle handler; the default sends real requests.
    5563         */
    56         public function __construct( string $base_url, string $secret, int $timeout = 10 ) {
     64        public function __construct( string $base_url, string $secret, int $timeout = 10, ?callable $handler = null ) {
    5765                $this->base_url = rtrim( $base_url, '/' ) . '/';
    5866                $this->secret   = $secret;
    5967                $this->timeout  = $timeout;
     68                $this->handler  = $handler;
    6069        }
    6170
    6271        /**
    63          * Creates a client from the module configuration.
     72         * Creates a client from the module configuration, or returns the one tests bound.
    6473         *
    6574         * @param int $timeout Request timeout in seconds.
    … …  
    6776         */
    6877        public static function from_config( int $timeout = 10 ): self {
     78                if ( app()->bound( self::class ) ) {
     79                        return app( self::class );
     80                }
     81
    6982                return new self(
    7083                        (string) config( 'wporgsidebar.api_url' ),
    … …  
    125138
    126139                try {
    127                         $http     = new \GuzzleHttp\Client( \Helper::setGuzzleDefaultOptions( array( 'timeout' => $this->timeout ) ) );
     140                        $options = array( 'timeout' => $this->timeout );
     141                        if ( $this->handler ) {
     142                                $options['handler'] = \GuzzleHttp\HandlerStack::create( $this->handler );
     143                        }
     144
     145                        $http     = new \GuzzleHttp\Client( \Helper::setGuzzleDefaultOptions( $options ) );
    128146                        $response = $http->post(
    129147                                $this->base_url . ltrim( $endpoint, '/' ),
  • sites/trunk/freescout.wordpress.net/Modules/WPOrgSidebar/tests/SidebarTest.php

    r15239 r15258  
    1212use App\Conversation;
    1313use App\User;
     14use GuzzleHttp\Handler\MockHandler;
     15use GuzzleHttp\Promise\PromiseInterface;
     16use GuzzleHttp\Psr7\Response;
    1417use Modules\WPOrgSidebar\Providers\WPOrgSidebarServiceProvider;
     18use Modules\WPOrgSidebar\Services\Client;
     19use Psr\Http\Message\RequestInterface;
    1520use WordPressdotorg\FreeScout\Tests\TestCase;
    1621
    … …  
    105110                        ->assertExactJson( array( 'html' => '' ) );
    106111        }
     112
     113        /**
     114         * The account a bounce or Slack notification names is only asked for when the agent asks for it.
     115         *
     116         * @return void
     117         */
     118        public function test_asks_for_the_related_account_on_request(): void {
     119                $payloads = array();
     120                $this->app->instance(
     121                        Client::class,
     122                        new Client(
     123                                'https://api.wordpress.test/',
     124                                'test-secret',
     125                                5,
     126                                static function ( RequestInterface $request ) use ( &$payloads ): PromiseInterface {
     127                                        $payloads[] = json_decode( (string) $request->getBody(), true );
     128
     129                                        return ( new MockHandler( array( new Response( 200, array(), '{"html":"<p>Panel</p>"}' ) ) ) )( $request, array() );
     130                                }
     131                        )
     132                );
     133                $user = $this->create_user();
     134
     135                $this->actingAs( $user )->get( '/wporgsidebar/' . $this->conversation->id . '/profile' )->assertStatus( 200 );
     136                $this->actingAs( $user )->get( '/wporgsidebar/' . $this->conversation->id . '/profile?related=1' )->assertStatus( 200 );
     137
     138                $this->assertArrayNotHasKey( 'related', $payloads[0] );
     139                $this->assertTrue( $payloads[1]['related'] );
     140        }
    107141}
  • sites/trunk/freescout.wordpress.net/README.md

    r15252 r15258  
    4040|---|---|
    4141| `WPORG_API_SECRET` | Shared secret; must match `FREESCOUT_SECRET` on api.wordpress.org. |
    42 | `WPORG_SSO_IDP_CERT` | The identity provider's signing certificate, on one line, with or without the BEGIN/END lines. Until it and `WPORG_API_SECRET` are set, logins stay as they are, so users can be connected first. Once they are, new logins go through WordPress.org, and sessions from before go on; Manage » System » Tools » Logout Users ends them. |
     42| `WPORG_SSO_IDP_CERT` | The identity provider's signing certificate, on one line, with or without the BEGIN/END lines. Until it and `WPORG_API_SECRET` are set, logins stay as they are, so users can be connected first. Once they are, new logins go through WordPress.org, and sessions from before go on for a day; Manage » System » Tools » Logout Users ends them sooner. After that day, switching enforcement off and on again ends every session from while it was off, including a password login of whoever switches it back on: have break-glass or a connected account ready. |
    4343
    4444Optional:
Note: See TracChangeset for help on using the changeset viewer.