Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
60.16% covered (warning)
60.16%
299 / 497
77.27% covered (warning)
77.27%
17 / 22
CRAP
0.00% covered (danger)
0.00%
0 / 1
PopulateCheckUserTablesWithSimulatedData
60.90% covered (warning)
60.90%
299 / 491
77.27% covered (warning)
77.27%
17 / 22
724.10
0.00% covered (danger)
0.00%
0 / 1
 __construct
100.00% covered (success)
100.00%
38 / 38
100.00% covered (success)
100.00%
1 / 1
1
 execute
4.59% covered (danger)
4.59%
5 / 109
0.00% covered (danger)
0.00%
0 / 1
524.31
 ensureArgumentIsInt
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
4
 applyRemainderAction
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
2
 createRegisteredUser
93.33% covered (success)
93.33%
14 / 15
0.00% covered (danger)
0.00%
0 / 1
3.00
 getRandomFloat
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 mtRand
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 randomlyAssignXFFHeader
72.73% covered (warning)
72.73%
8 / 11
0.00% covered (danger)
0.00%
0 / 1
5.51
 returnRandomIpExceptExcluded
100.00% covered (success)
100.00%
9 / 9
100.00% covered (success)
100.00%
1 / 1
2
 getNewIp
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
1
 generateNewIp
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
2
 generateNewIPv4
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
1
 generateNewIPv6
100.00% covered (success)
100.00%
32 / 32
100.00% covered (success)
100.00%
1 / 1
11
 getNewUserAgentAndAssociatedClientHints
100.00% covered (success)
100.00%
19 / 19
100.00% covered (success)
100.00%
1 / 1
4
 performInsertBatch
0.00% covered (danger)
0.00%
0 / 82
0.00% covered (danger)
0.00%
0 / 1
650
 simulateLogAction
100.00% covered (success)
100.00%
23 / 23
100.00% covered (success)
100.00%
1 / 1
5
 performEdit
90.00% covered (success)
90.00%
18 / 20
0.00% covered (danger)
0.00%
0 / 1
4.02
 incrementAndCheck
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
1
 setNewRandomFakeTime
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
2
 moveFakeTimeForward
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
1
 initUserAgentAndClientHintsCombos
100.00% covered (success)
100.00%
99 / 99
100.00% covered (success)
100.00%
1 / 1
1
 getPrefix
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
1<?php
2
3namespace MediaWiki\CheckUser\Maintenance;
4
5use ContentHandler;
6use MailAddress;
7use Maintenance;
8use ManualLogEntry;
9use MediaWiki\Auth\AuthenticationResponse;
10use MediaWiki\Auth\AuthManager;
11use MediaWiki\CheckUser\ClientHints\ClientHintsData;
12use MediaWiki\CheckUser\Hooks as CheckUserHooks;
13use MediaWiki\CheckUser\Services\UserAgentClientHintsManager;
14use MediaWiki\MediaWikiServices;
15use MediaWiki\Request\FauxRequest;
16use MediaWiki\Title\Title;
17use MediaWiki\User\User;
18use MediaWiki\User\UserIdentity;
19use MediaWiki\User\UserIdentityValue;
20use MediaWiki\User\UserRigorOptions;
21use RequestContext;
22use Wikimedia\IPUtils;
23use Wikimedia\Timestamp\ConvertibleTimestamp;
24
25$IP = getenv( 'MW_INSTALL_PATH' );
26if ( $IP === false ) {
27    $IP = __DIR__ . '/../../..';
28}
29require_once "$IP/maintenance/Maintenance.php";
30
31/**
32 * Populates the CheckUser tables with simulated data that can be useful for
33 * testing.
34 *
35 * WARNING: This should never be run on production wikis. This is intended only for
36 * local testing wikis where the DB can be cleared without issue.
37 */
38class PopulateCheckUserTablesWithSimulatedData extends Maintenance {
39
40    private const VALID_LOG_EVENTS = [
41        'move' => [ 'move', 'move_redir' ],
42        'delete' => [ 'delete', 'restore' ],
43        'suppress' => [ 'delete' ],
44        'merge' => [ 'merge' ]
45    ];
46
47    /** @var array<string,?ClientHintsData> */
48    private array $userAgentsToClientHintsMap;
49
50    private CheckUserHooks $hooks;
51
52    private User $userToEmailAndSendPasswordResetsFor;
53
54    private ?ClientHintsData $currentClientHintsData;
55
56    private array $ipv4Ranges;
57
58    private array $ipv6Ranges;
59
60    private array $ipsToUse;
61
62    private FauxRequest $mainRequest;
63
64    public function __construct() {
65        parent::__construct();
66        $this->addDescription( 'If you use --num-temp with this script, set ' .
67            '$wgTempAccountNameAcquisitionThrottle to null to avoid rate limiting on ' .
68            'temporary account name acquisitions' );
69        $this->addOption(
70            'num-users',
71            'How many users should be created and used for the simulated actions. ' .
72            'The number of actions performed will roughly be split equally between the users. Default is 10.'
73        );
74        $this->addOption(
75            'num-anon',
76            'How many IPs should be used for the simulated actions. ' .
77            'The number of actions performed will roughly be split equally between the IPs. Default is 5.'
78        );
79        $this->addOption(
80            'num-temp',
81            'How many temporary accounts should be used for the simulated actions. ' .
82            'The number of actions performed will roughly be split equally between the temporary accounts.' .
83            'This is ignored if temporary account creation is disabled. If not ignored, the default is 10.'
84        );
85        $this->addOption(
86            'num-used-ips',
87            'How many IPs to select from the ranges in ranges-for-ips. Must not be smaller than num-anon. ' .
88            'These IPs will be used for anon edits, temporary account and user actions. These will also be used ' .
89            'in the XFF header (if set) for actions. Default is 5.'
90        );
91        $this->addOption(
92            'ranges-for-ips',
93            'What ranges should the IPs be selected from. Default is one IPv4 and IPv6 range inside ' .
94            'ranges defined as internal.',
95            false, false, false, true
96        );
97        $this->addArg(
98            'count',
99            'How many items to be added to the CheckUser tables. Default is 1,000 items.',
100            false
101        );
102
103        $this->requireExtension( 'CheckUser' );
104    }
105
106    public function execute() {
107        // Check development mode is enabled
108        if ( $this->getConfig()->get( 'CheckUserDeveloperMode' ) !== true ) {
109            $this->fatalError(
110                "CheckUser development mode must be enabled to use this script. To do this, set " .
111                "wgCheckUserDeveloperMode to true. Only do this on localhost testing wikis."
112            );
113        }
114        // Set-up and argument parsing.
115        $count = $this->ensureArgumentIsInt(
116            $this->getArg( 0, 1000 ),
117            'Count'
118        );
119        $numUsers = $this->ensureArgumentIsInt(
120            $this->getOption( 'num-users', 10 ),
121            'Number of registered users'
122        );
123        $numAnon = $this->ensureArgumentIsInt(
124            $this->getOption( 'num-anon', 5 ),
125            'Number of anonymous users'
126        );
127        $numTemp = $this->ensureArgumentIsInt(
128            $this->getOption( 'num-temp', 10 ),
129            'Number of temporary users'
130        );
131        $numIpsToUse = $this->ensureArgumentIsInt(
132            $this->getOption( 'num-used-ips', 5 ),
133            'Number of IPs to use'
134        );
135        $ipRanges = $this->getOption( 'ranges-for-ips', [ '127.0.0.1/24', 'fd12:3456:789a:1::/40' ] );
136
137        foreach ( $ipRanges as $range ) {
138            if ( !IPUtils::isValidRange( $range ) ) {
139                $this->fatalError( 'range-for-ips option must be a valid IP address range.' );
140            }
141            if ( IPUtils::isIPv4( $range ) ) {
142                $this->ipv4Ranges[] = $range;
143            } else {
144                $this->ipv6Ranges[] = $range;
145            }
146        }
147
148        if ( $numAnon > $numIpsToUse ) {
149            $this->fatalError( 'Number of anon users making edits should not exceed the number of IPs used.' );
150        }
151
152        if ( !$this->getServiceContainer()->getTempUserConfig()->isEnabled() ) {
153            // Only add temporary users if temporary user creation is enabled.
154            $numTemp = 0;
155        }
156
157        $actionsPerActor = intval( floor( $count / array_sum( [ $numUsers, $numAnon, $numTemp ] ) ) );
158        $remainderActions = $count % array_sum( [ $numUsers, $numAnon, $numTemp ] );
159
160        if ( $actionsPerActor < 5 ) {
161            $minCount = array_sum( [ $numUsers, $numAnon, $numTemp ] ) * 5;
162            $this->fatalError(
163                "Minimum actions per actor must be 5. Increase the 'count' argument to at least {$minCount}."
164            );
165        }
166
167        // Start code that can assume it is safe to perform un-reversible testing actions.
168        $this->hooks = new CheckUserHooks();
169        $services = MediaWikiServices::getInstance();
170        $userForEmails = $this->createRegisteredUser();
171        if ( $userForEmails === null ) {
172            $this->fatalError(
173                "Unable to create a new user to be used as the target for emails and password resets.\n"
174            );
175        }
176        $this->userToEmailAndSendPasswordResetsFor = $userForEmails;
177        $this->mainRequest = new FauxRequest();
178        RequestContext::getMain()->setRequest( $this->mainRequest );
179        $this->initUserAgentAndClientHintsCombos();
180
181        // Get $numIpsToUse IPs.
182        $this->ipsToUse = [];
183        // First try to get one random IPv4 in the allowed IPv4 range(s).
184        if ( count( $this->ipv4Ranges ) ) {
185            $this->ipsToUse[] = $this->generateNewIPv4();
186            $numIpsToUse--;
187        }
188        // Next try to get one random IPv6 in the allowed IPv6 range(s).
189        if ( count( $this->ipv6Ranges ) && $numIpsToUse > 0 ) {
190            $this->ipsToUse[] = $this->generateNewIPv6();
191            $numIpsToUse--;
192        }
193        // If IPs can still be chosen then randomly generate
194        // them from either IPv4 or IPv6 ranges.
195        if ( $numIpsToUse > 0 ) {
196            foreach ( range( 0, $numIpsToUse ) as $ignored ) {
197                $this->ipsToUse[] = $this->generateNewIp();
198            }
199        }
200
201        // Get the first IP, user agent and client hints data
202        $this->getNewIp();
203        $this->getNewUserAgentAndAssociatedClientHints();
204
205        // First populate using users
206        for ( $i = 0; $i < $numUsers; $i++ ) {
207            $actionsLeft = $actionsPerActor;
208            $this->applyRemainderAction( $actionsLeft, $remainderActions );
209
210            // Find a username that is not already being used
211            $this->setNewRandomFakeTime();
212            $lowerLimit = time() - ConvertibleTimestamp::time();
213            $user = $this->createRegisteredUser();
214            // Creating an account causes a log event.
215            $actionsLeft--;
216            if ( $user === null ) {
217                $this->output( "Unable to create new user. Skipping this actor.\n" );
218                continue;
219            }
220            $this->output( "Processing user with username {$user->getName()}.\n" );
221
222            while ( $actionsLeft > 0 ) {
223                if ( $this->getRandomFloat() < 0.3 ) {
224                    // Assign a new IP to the main request 30% of the time.
225                    $this->getNewIp();
226                }
227
228                $actionsLeft -= $this->performInsertBatch( $user, $actionsLeft, $lowerLimit );
229            }
230        }
231
232        // Secondly populate using temporary accounts
233        for ( $i = 0; $i < $numTemp; $i++ ) {
234            $actionsLeft = $actionsPerActor;
235            $this->applyRemainderAction( $actionsLeft, $remainderActions );
236
237            $this->setNewRandomFakeTime();
238            $lowerLimit = time() - ConvertibleTimestamp::time();
239            $user = $services->getTempUserCreator()->create(
240                null, $this->mainRequest
241            )->getUser();
242            // Creating a temporary user creates a log event.
243            $actionsLeft--;
244            $this->output( "Processing temporary user with username {$user->getName()}.\n" );
245
246            while ( $actionsLeft > 0 ) {
247                if ( $this->getRandomFloat() < 0.3 ) {
248                    // Assign a new IP to the main request 30% of the time.
249                    $this->getNewIp();
250                }
251
252                $actionsLeft -= $this->performInsertBatch( $user, $actionsLeft, $lowerLimit );
253            }
254        }
255
256        // Lastly populate using IPs
257        if ( count( $this->ipsToUse ) < 3 ) {
258            // If less than 3 IPs to choose from, keep the original ordering.
259            $ipsInOrder = $this->ipsToUse;
260        } else {
261            // If three or more IPs to choose from, pick the first two and a random
262            // selection of the other IPs. This ensures at least one IPv4 and IPv6
263            // address is used if allowed.
264            $ipsInOrder = array_slice( $this->ipsToUse, 2 );
265            shuffle( $ipsInOrder );
266            array_unshift( $ipsInOrder, $this->ipsToUse[0], $this->ipsToUse[1] );
267        }
268        for ( $i = 0; $i < $numAnon; $i++ ) {
269            $actionsLeft = $actionsPerActor;
270            $this->applyRemainderAction( $actionsLeft, $remainderActions );
271
272            $user = UserIdentityValue::newAnonymous( IPUtils::prettifyIP( $ipsInOrder[$i] ) );
273            // Assign the request IP as the anon user being used for this loop.
274            RequestContext::getMain()->getRequest()->setIP( $user->getName() );
275            $this->output( "Processing anon user with IP {$user->getName()}.\n" );
276
277            while ( $actionsLeft > 0 ) {
278                $this->randomlyAssignXFFHeader( $user->getName() );
279                $actionsLeft -= $this->performInsertBatch( $user, $actionsLeft );
280            }
281        }
282    }
283
284    /**
285     * Ensure an argument provided via the command line is an integer.
286     * If it is not, then exit the script with a fatal error message.
287     *
288     * @param mixed $argument The argument from the command line (usually in string form)
289     * @param string $name The name of the argument used if the argument is not an integer
290     * in the fatal error message.
291     * @return int The argument as an integer (exit is called if the argument was invalid).
292     */
293    private function ensureArgumentIsInt( $argument, string $name ): int {
294        if ( ( !$argument || !intval( $argument ) ) && $argument !== '0' ) {
295            $this->fatalError( "$name must be an integer" );
296        }
297        return intval( $argument );
298    }
299
300    /**
301     * Reduce the remainder argument by 1 and increase the actions left
302     * argument by 1, as long as the remainder argument is above 0.
303     *
304     * @param int &$actionsLeft The actions left for an actor
305     * @param int &$remainderActions The remainder of the floor division
306     * @return void
307     */
308    private function applyRemainderAction( int &$actionsLeft, int &$remainderActions ) {
309        if ( $remainderActions > 0 ) {
310            $actionsLeft += 1;
311            $remainderActions -= 1;
312        }
313    }
314
315    /**
316     * Create a user on the wiki with a username
317     * prefixed with CheckUserSimulated and then
318     * a random string of hexadecimal characters.
319     *
320     * @return ?User A user that has just been created or null if this failed.
321     */
322    private function createRegisteredUser(): ?User {
323        $services = MediaWikiServices::getInstance();
324        // Find a username that doesn't exist.
325        $attemptsMade = 0;
326        do {
327            $user = $services->getUserFactory()->newFromName(
328                $this->getPrefix() . wfRandomString(), UserRigorOptions::RIGOR_CREATABLE
329            );
330            if ( $attemptsMade > 100 ) {
331                return null;
332            }
333            $attemptsMade++;
334        } while ( $user === null || $user->isRegistered() );
335        '@phan-var User $user';
336        // Create an account using this username
337        $services->getAuthManager()->autoCreateUser(
338            $user,
339            AuthManager::AUTOCREATE_SOURCE_MAINT,
340            false
341        );
342        return $user;
343    }
344
345    /**
346     * Calls wfRandom and returns the value.
347     *
348     * Called by other code in this class so that tests
349     * can mock the return value.
350     *
351     * @return float A float in the range [0, 1]
352     */
353    protected function getRandomFloat(): float {
354        return floatval( wfRandom() );
355    }
356
357    /**
358     * Calls mt_rand and returns the value.
359     *
360     * Called by code in this class so that tests
361     * can mock the return value to test behaviour
362     * that is determined randomly using mt_rand.
363     *
364     * @param int $min See mt_rand documentation
365     * @param int $max See mt_rand documentation
366     * @return int A random integer, see mt_rand documentation for more details.
367     */
368    protected function mtRand( $min, $max ): int {
369        return mt_rand( $min, $max );
370    }
371
372    /**
373     * This method 30% of the time will apply a XFF header to the request
374     * and in other cases will clear any existing XFF header.
375     *
376     * @todo Make the XFF IPs make a bit more sense than using random IPs and/or
377     * make some of the XFF strings trusted.
378     *
379     * @param string $currentIp The current IP address of the request that will not
380     * be used in the XFF header.
381     */
382    private function randomlyAssignXFFHeader( string $currentIp ): void {
383        if ( $this->getRandomFloat() < 0.3 ) {
384            $xffIp = $this->returnRandomIpExceptExcluded( [ $currentIp ] );
385            if ( !$xffIp ) {
386                $xffValue = false;
387            } else {
388                $xffValue = IPUtils::prettifyIP( $xffIp );
389                if ( $this->getRandomFloat() < 0.7 ) {
390                    $xffIp = $this->returnRandomIpExceptExcluded( [ $currentIp, $xffIp ] );
391                    if ( $xffIp ) {
392                        $xffValue = $xffValue . ', ' . IPUtils::prettifyIP( $xffIp );
393                    }
394                }
395            }
396        } else {
397            $xffValue = false;
398        }
399        $this->mainRequest->setHeaders( [ 'X-Forwarded-For' => $xffValue ] );
400    }
401
402    /**
403     * Return a random IP address from the list of IPs chosen
404     * in the property self::ipsToUse excluding those provided
405     * in the arguments.
406     *
407     * @param array $ipsExcluded The IPs to exclude from the random selection
408     * @return string|null A random IP or null if no IPs are left after the exclusion step.
409     */
410    private function returnRandomIpExceptExcluded( array $ipsExcluded ): ?string {
411        $ipsToChoose = array_flip( array_filter(
412            $this->ipsToUse,
413            static function ( $item ) use ( $ipsExcluded ) {
414                return !in_array( $item, $ipsExcluded );
415            }
416        ) );
417        if ( count( $ipsToChoose ) ) {
418            return array_rand( $ipsToChoose );
419        }
420        return null;
421    }
422
423    /**
424     * Randomly pick either an IPv4 or IPv6 address
425     * from the list of IPs that were already chosen randomly.
426     * Also assign the IP as the IP used in the main request.
427     *
428     * @return string The IP that was chosen
429     */
430    private function getNewIp(): string {
431        $ip = array_rand( array_flip( $this->ipsToUse ) );
432        $this->randomlyAssignXFFHeader( $ip );
433        RequestContext::getMain()->getRequest()->setIP( $ip );
434        return $ip;
435    }
436
437    /**
438     * Generate a randomly chosen IPv4 or IPv6
439     * address that sits within the allowed
440     * ranges. A IPv4 address is returned 50%
441     * of the time on average.
442     *
443     * @return string
444     */
445    private function generateNewIp(): string {
446        if ( $this->getRandomFloat() < 0.5 ) {
447            return $this->generateNewIPv4();
448        } else {
449            return $this->generateNewIPv6();
450        }
451    }
452
453    /**
454     * Randomly pick a new IPv4 address that comes from
455     * one of the defined ranges.
456     *
457     * @return string The IP that was chosen
458     */
459    private function generateNewIPv4(): string {
460        [ $start, $end ] = IPUtils::parseRange( array_rand( array_flip( $this->ipv4Ranges ) ) );
461        $start = ip2long( IPUtils::formatHex( $start ) );
462        $end = ip2long( IPUtils::formatHex( $end ) );
463        $ipAsLong = $this->mtRand( $start, $end );
464        $ip = long2ip( $ipAsLong );
465        return $ip;
466    }
467
468    /**
469     * Randomly pick a new IPv6 address that comes
470     * from one of the defined IPv6 ranges.
471     *
472     * @return string The IP that was chosen
473     */
474    private function generateNewIPv6(): string {
475        [ $start, $end ] = IPUtils::parseRange( array_rand( array_flip( $this->ipv6Ranges ) ) );
476        $ip = '';
477        $seenDifference = false;
478        $lastOnEdgeOfRange = false;
479        for ( $i = 0; $i < strlen( $start ); $i++ ) {
480            if ( !$seenDifference && $start[$i] === $end[$i] ) {
481                // Same character in both end and start of range
482                // therefore the randomly selected IPv6 must have
483                // this character
484                $ip .= $start[$i];
485            } elseif ( !$seenDifference ) {
486                // Not the same character, but this is the first difference
487                // seen in the characters between $start and $end so far.
488                //
489                // Choose a random hex character between the hex characters in
490                // $start and $end.
491                $startAtiAsDec = hexdec( $start[$i] );
492                $endAtiAsDec = hexdec( $end[$i] );
493                $newHexCharacter = dechex( $this->mtRand( $startAtiAsDec, $endAtiAsDec ) );
494                $ip .= $newHexCharacter;
495                $seenDifference = true;
496                // If the randomly selected hex character is the same as the
497                // start of the end character, then the next hex character
498                // must be greater than or less than respectively than
499                // the character at $i.
500                if ( $newHexCharacter == $start[$i] ) {
501                    $lastOnEdgeOfRange = 'start';
502                } elseif ( $newHexCharacter == $end[$i] ) {
503                    $lastOnEdgeOfRange = 'end';
504                }
505            } elseif ( $lastOnEdgeOfRange === 'start' ) {
506                // Ensure the random selection never exceeds the value
507                // at $start[$i]. This is to prevent the IP being outside the range.
508                $startAtiAsDec = hexdec( $start[$i] );
509                $newHexCharacter = dechex( $this->mtRand( $startAtiAsDec, 15 ) );
510                $ip .= $newHexCharacter;
511                if ( $newHexCharacter !== $start[$i] ) {
512                    $lastOnEdgeOfRange = false;
513                }
514            } elseif ( $lastOnEdgeOfRange === 'end' ) {
515                // Ensure the random selection never exceeds the value
516                // at $end[$i]. This is to prevent the IP being outside the range.
517                $endAtiAsDec = hexdec( $end[$i] );
518                $newHexCharacter = dechex( $this->mtRand( 0, $endAtiAsDec ) );
519                $ip .= $newHexCharacter;
520                if ( $newHexCharacter !== $end[$i] ) {
521                    $lastOnEdgeOfRange = false;
522                }
523            } else {
524                // Randomly choose any hex character.
525                $ip .= dechex( $this->mtRand( 0, 15 ) );
526            }
527        }
528        $ip = IPUtils::formatHex( $ip );
529        return $ip;
530    }
531
532    /**
533     * This method randomly chooses a User-Agent header string, assigns that
534     * to the request and then applies Client Hints headers if the browser
535     * that uses the selected User-Agent supports Client Hints.
536     *
537     * @return void
538     */
539    private function getNewUserAgentAndAssociatedClientHints(): void {
540        $userAgent = array_rand( $this->userAgentsToClientHintsMap );
541        $this->mainRequest->setHeader( 'User-Agent', $userAgent );
542        /** @var ?ClientHintsData $clientHintsData */
543        $clientHintsData = $this->userAgentsToClientHintsMap[$userAgent];
544        // Unset any existing Client Hints data.
545        $clientHintHeadersToUnset = array_filter(
546            array_keys( $this->mainRequest->getAllHeaders() ),
547            static fn ( $headerName ) => str_starts_with( $headerName, 'SEC-CH-UA' )
548        );
549        foreach ( $clientHintHeadersToUnset as $clientHintHeader ) {
550            $this->mainRequest->setHeaders( [ $clientHintHeader => false ] );
551        }
552        if ( $clientHintsData !== null ) {
553            // Set the Client Hints headers in the faux request.
554            $clientHintHeadersToSet = array_filter( array_keys(
555                $this->getConfig()->get( 'CheckUserClientHintsHeaders' )
556            ) );
557            foreach ( $clientHintHeadersToSet as $clientHintHeader ) {
558                $propertyName = ClientHintsData::HEADER_TO_CLIENT_HINTS_DATA_PROPERTY_NAME[$clientHintHeader];
559                $this->mainRequest->setHeader(
560                    $clientHintHeader, $clientHintsData->jsonSerialize()[$propertyName]
561                );
562            }
563        }
564        $this->currentClientHintsData = $clientHintsData;
565    }
566
567    /**
568     * Perform a insert batch for a given actor. The inserts will stop and the method
569     * will return early if the actions performed reaches $actionsLeft.
570     *
571     * This method inserts edits by actually performing the edit. It uses ManualLogEntry
572     * to create log entries that are visible in Special:Log. Other log events, such as
573     * logging in, which are not shown in Special:Log are created by calling the
574     * specified hook handler.
575     *
576     * @param UserIdentity $actor The user/IP/temporary account that will perform these actions
577     * @param int &$actionsLeft Must be greater than 0. Represents the number of actions left.
578     * @param ?int $lowerLimit The furthest ago the random fake time can be from the current time in seconds.
579     * @return int The actions actually performed in this batch.
580     */
581    private function performInsertBatch( UserIdentity $actor, int &$actionsLeft, ?int $lowerLimit = null ): int {
582        $this->setNewRandomFakeTime( $lowerLimit );
583        if ( $this->getRandomFloat() < 0.3 ) {
584            // Assign a new user agent and client hints combo 30% of the time
585            $this->getNewUserAgentAndAssociatedClientHints();
586        }
587        $services = MediaWikiServices::getInstance();
588        /** @var UserAgentClientHintsManager $userAgentClientHintsManager */
589        $userAgentClientHintsManager = $services->getService( 'UserAgentClientHintsManager' );
590        $actorAsUserObject = $services->getUserFactory()->newFromUserIdentity( $actor );
591        $actionsPerformed = 0;
592        // Simulate a failed login 10% of the time.
593        if ( $actor->isRegistered() && $this->getRandomFloat() < 0.1 ) {
594            $failReasons = [];
595            // Simulate good password 30% of the time.
596            if ( $this->getRandomFloat() < 0.3 ) {
597                $failReasons[] = "good password";
598                // The phrase "locked" comes from the CentralAuth extension and indicates that
599                // the account login was made on an account that was locked but the request
600                // otherwise used the correct password.
601                $failReasons[] = "locked";
602            } else {
603                $failReasons[] = "bad password";
604            }
605            $this->hooks->onAuthManagerLoginAuthenticateAudit(
606                AuthenticationResponse::newFail( wfMessage( 'test' ), $failReasons ),
607                $actorAsUserObject,
608                $actor->getName(),
609                []
610            );
611            if ( !$this->incrementAndCheck( $actionsPerformed, $actionsLeft ) ) {
612                return $actionsPerformed;
613            }
614        }
615        if ( $actor->isRegistered() ) {
616            // Simulate a login.
617            $this->hooks->onAuthManagerLoginAuthenticateAudit(
618                AuthenticationResponse::newPass( $actor->getName() ),
619                $actorAsUserObject,
620                $actor->getName(),
621                []
622            );
623            if ( !$this->incrementAndCheck( $actionsPerformed, $actionsLeft ) ) {
624                return $actionsPerformed;
625            }
626        }
627        // Perform a random number of edits, capped at 3.
628        $editsToPerform = intval( $this->getRandomFloat() * 3 );
629        if ( !$actor->isRegistered() ) {
630            // Always perform at least one edit if an anon user.
631            $editsToPerform += 1;
632        }
633        foreach ( range( 0, $editsToPerform ) as $ignored ) {
634            $title = null;
635            if ( $this->getRandomFloat() < 0.3 ) {
636                $title = Title::newFromText( $this->getPrefix() . 'Existing page' );
637            }
638            $revisionId = $this->performEdit( $actorAsUserObject, $title );
639            // Send a REST API request for the edit with Client Hints data, if there is data specified.
640            if ( $this->currentClientHintsData !== null && $revisionId !== null ) {
641                $userAgentClientHintsManager->insertClientHintValues(
642                    ClientHintsData::newFromJsApi( $this->currentClientHintsData->jsonSerialize() ),
643                    $revisionId,
644                    'revision'
645                );
646            }
647            if ( !$this->incrementAndCheck( $actionsPerformed, $actionsLeft ) ) {
648                return $actionsPerformed;
649            }
650        }
651        // Simulate a random number of log actions, capped at 2.
652        $logsToPerform = intval( $this->getRandomFloat() * 2 );
653        foreach ( range( 0, $logsToPerform ) as $ignored ) {
654            if ( $actor->isRegistered() ) {
655                $type = array_rand( self::VALID_LOG_EVENTS );
656            } else {
657                $type = 'move';
658            }
659            $action = array_rand( array_flip( self::VALID_LOG_EVENTS[$type] ) );
660            $this->simulateLogAction( $type, $action, $actor );
661            if ( !$this->incrementAndCheck( $actionsPerformed, $actionsLeft ) ) {
662                return $actionsPerformed;
663            }
664        }
665        // Simulate an email 10% of the time
666        if ( $actor->isRegistered() && $this->getRandomFloat() < 0.1 ) {
667            $from = MailAddress::newFromUser( $actorAsUserObject );
668            $to = MailAddress::newFromUser( $this->userToEmailAndSendPasswordResetsFor );
669            $subject = 'Test';
670            $text = wfRandomString();
671            $error = [];
672            $this->hooks->onEmailUser( $to, $from, $subject, $text, $error );
673            if ( !$this->incrementAndCheck( $actionsPerformed, $actionsLeft ) ) {
674                return $actionsPerformed;
675            }
676        }
677        // Send password reset 10% of the time.
678        if ( $this->getRandomFloat() < 0.1 ) {
679            $this->hooks->onUser__mailPasswordInternal(
680                $actorAsUserObject,
681                RequestContext::getMain()->getRequest()->getIP(),
682                $this->userToEmailAndSendPasswordResetsFor
683            );
684            if ( !$this->incrementAndCheck( $actionsPerformed, $actionsLeft ) ) {
685                return $actionsPerformed;
686            }
687        }
688        // Logout 50% of the time
689        if ( $actor->isRegistered() && $this->getRandomFloat() < 0.5 ) {
690            $html = '';
691            $anonUser = $services->getUserFactory()->newFromName(
692                RequestContext::getMain()->getRequest()->getIP(),
693                UserRigorOptions::RIGOR_NONE
694            );
695            if ( $anonUser ) {
696                $this->hooks->onUserLogoutComplete( $anonUser, $html, $actor->getName() );
697            }
698            $this->incrementAndCheck( $actionsPerformed, $actionsLeft );
699        }
700        return $actionsPerformed;
701    }
702
703    /**
704     * Simulate a log action by creating an entry in Special:Log but not actually
705     * performing the action that is referenced in the log entry.
706     *
707     * Then tell CheckUser about this log entry, so that it is stored in the
708     * results list.
709     *
710     * @param string $type The log type
711     * @param string $action The log subtype (otherwise known as action)
712     * @param UserIdentity $actor The intended performer of this log action.
713     * @return void
714     */
715    private function simulateLogAction( string $type, string $action, UserIdentity $actor ): void {
716        $logEntry = new ManualLogEntry( $type, $action );
717        $logEntry->setPerformer( $actor );
718        $logEntry->setTarget( Title::newFromText( $this->getPrefix() . 'Existing page' ) );
719        $logEntry->setComment( wfRandomString() );
720        if ( $type === 'move' ) {
721            $logEntry->setParameters( [
722                '4::target' => $this->getPrefix() . wfRandomString(),
723                '5::noredir' => '0'
724            ] );
725        } elseif ( $type === 'merge' ) {
726            $logEntry->setParameters( [
727                '4::dest' => $this->getPrefix() . wfRandomString(),
728                '5::mergepoint' => $logEntry->getTimestamp()
729            ] );
730        } elseif ( $type === 'delete' && $action === 'undelete' ) {
731            $logEntry->setParameters( [
732                ':assoc:count' => [
733                    'revisions' => 123,
734                    'files' => 1,
735                ],
736            ] );
737        }
738        $id = $logEntry->insert();
739        $this->hooks->onRecentChange_save( $logEntry->getRecentChange( $id ) );
740    }
741
742    /**
743     * Actually perform an edit using the given actor
744     * that is published to Special:RecentChanges (and
745     * then by extension Special:CheckUser)
746     *
747     * @param User $actor The user which is performing the edit
748     * @param Title|null $title The title of the page the edit will be performed on. Use null for a random title.
749     * @return int|null The revision ID of the edit if successful, otherwise null.
750     */
751    private function performEdit( User $actor, ?Title $title ): ?int {
752        $tags = 0;
753        if ( $this->getRandomFloat() < 0.5 ) {
754            // Add minor edit flag 50% of the time.
755            $tags = EDIT_MINOR;
756        }
757        $title ??= Title::newFromText( $this->getPrefix() . wfRandomString() );
758        if ( !$title ) {
759            return null;
760        }
761        $page = $this->getServiceContainer()->getWikiPageFactory()->newFromTitle( $title );
762        $status = $page->doUserEditContent(
763            ContentHandler::makeContent(
764                wfRandomString(),
765                $title,
766                // Regardless of how the wiki is configure or what extensions are present,
767                // force this page to be a wikitext one.
768                CONTENT_MODEL_WIKITEXT
769            ),
770            $actor,
771            wfRandomString(),
772            $tags
773        );
774        if ( !$status->isOK() ) {
775            return null;
776        }
777        return $status->getNewRevision()->getId();
778    }
779
780    /**
781     * Increment the actions performed counter, move the fake time forward
782     * by a random time no greater than 240 seconds and then check if more
783     * actions can be performed by checking against the second parameter
784     *
785     * @param int &$actionsPerformed The number of actions performed on this insert batch
786     * @param int $actionsLeft The number of actions left to perform for this actor
787     * @return bool Whether more actions can be performed
788     */
789    private function incrementAndCheck( int &$actionsPerformed, int $actionsLeft ): bool {
790        $actionsPerformed++;
791        $this->moveFakeTimeForward();
792        return $actionsPerformed < $actionsLeft;
793    }
794
795    /**
796     * Set the time to a fake time between now and CUDMaxAge seconds ago.
797     *
798     * @param ?int $lowerLimit The maximum number of seconds ago this random timestamp can be. An hour
799     *  is always added to this number.
800     * @return void
801     */
802    private function setNewRandomFakeTime( ?int $lowerLimit = null ): void {
803        // Clear any fake time (to allow the ConvertibleTimestamp::time() call to use the real time).
804        ConvertibleTimestamp::setFakeTime( false );
805        // Set the new fake time
806        //
807        // Ensure the new fake time is at least an hour ago from the actual time.
808        $newFakeTime = ConvertibleTimestamp::time() - 3600;
809        // Ensure the new fake time is appropriately chosen from any time period results can be in.
810        if ( $lowerLimit === null ) {
811            // Default is to ensure random time cannot be more than CUDMaxAge seconds ago minus 2 hours.
812            $lowerLimit = $this->getConfig()->get( 'CUDMaxAge' ) - ( 3600 * 2 );
813        }
814        $newFakeTime -= intval( $this->getRandomFloat() * $lowerLimit );
815        ConvertibleTimestamp::setFakeTime( $newFakeTime );
816    }
817
818    /**
819     * Move the fake time forward by a random number of seconds between 0 and 240 seconds.
820     *
821     * @return void
822     */
823    private function moveFakeTimeForward(): void {
824        ConvertibleTimestamp::setFakeTime(
825            ConvertibleTimestamp::time() + intval( $this->getRandomFloat() * 239 ) + 1
826        );
827    }
828
829    /**
830     * Initialise the User-Agent header and Client Hints combinations
831     * as the ClientHints objects cannot be created in a constant property.
832     *
833     * @return void
834     */
835    private function initUserAgentAndClientHintsCombos(): void {
836        $this->userAgentsToClientHintsMap = [
837            'Mozilla/5.0 (iPhone13,2; U; CPU iPhone OS 14_0 like Mac OS X) AppleWebKit/602.1.50 ' .
838            '(KHTML, like Gecko) Version/10.0 Mobile/15E148 Safari/602.1' =>
839                null,
840            'Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:109.0) Gecko/20100101 Firefox/116.0' =>
841                null,
842        ];
843        $this->userAgentsToClientHintsMap[
844            'Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) ' .
845            'Chrome/115.0.0.0 Mobile Safari/537.36'
846        ] = new ClientHintsData(
847            "",
848            "64",
849            [
850                [ "brand" => "Not/A)Brand", "version" => "99" ],
851                [ "brand" => "Google Chrome", "version" => "115" ],
852                [ "brand" => "Chromium", "version" => "115" ],
853            ],
854            null,
855            [
856                [ "brand" => "Not/A)Brand", "version" => "99.0.0.0" ],
857                [ "brand" => "Google Chrome", "version" => "115.0.5790.171" ],
858                [ "brand" => "Chromium", "version" => "115.0.5790.171" ],
859            ],
860            true,
861            "SM-G965U",
862            "Android",
863            "10.0.0",
864            '"Not/A)Brand";v="99", "Google Chrome";v="115", "Chromium";v="115"',
865            false
866        );
867        $this->userAgentsToClientHintsMap[
868            'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) ' .
869            'Chrome/112.0.0.0 Safari/537.36 OPR/98.0.0.0'
870        ] = new ClientHintsData(
871            "x86",
872            "64",
873            [
874                [ "brand" => "Chromium", "version" => "112" ],
875                [ "brand" => "Not_A Brand", "version" => "24" ],
876                [ "brand" => "Opera GX", "version" => "98" ],
877            ],
878            null,
879            [
880                [ "brand" => "Chromium", "version" => "112.0.5615.165" ],
881                [ "brand" => "Not_A Brand", "version" => "24.0.0.0" ],
882                [ "brand" => "Opera GX", "version" => "98.0.4759.82" ],
883            ],
884            false,
885            "",
886            "Windows",
887            "15.0.0",
888            '"Chromium";v="112", "Not_A Brand";v="24", "Opera GX";v="98"',
889            false
890        );
891        $this->userAgentsToClientHintsMap[
892            'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) ' .
893            'Chrome/115.0.0.0 Safari/537.36'
894        ] = new ClientHintsData(
895            "x86",
896            "64",
897            [
898                [ "brand" => "Not/A)Brand", "version" => "99" ],
899                [ "brand" => "Google Chrome", "version" => "115" ],
900                [ "brand" => "Chromium", "version" => "115" ],
901            ],
902            null,
903            [
904                [ "brand" => "Not/A)Brand", "version" => "99.0.0.0" ],
905                [ "brand" => "Google Chrome", "version" => "115.0.5790.171" ],
906                [ "brand" => "Chromium", "version" => "115.0.5790.171" ],
907            ],
908            false,
909            "",
910            "Windows",
911            "15.0.0",
912            '"Not/A)Brand";v="99", "Google Chrome";v="115", "Chromium";v="115"',
913            false
914        );
915        $this->userAgentsToClientHintsMap[
916            'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) ' .
917            'Chrome/114.0.0.0 Safari/537.36'
918        ] = new ClientHintsData(
919            "x86",
920            null,
921            [
922                [ "brand" => "Not/A)Brand", "version" => "99" ],
923                [ "brand" => "Google Chrome", "version" => "114" ],
924                [ "brand" => "Chromium", "version" => "114" ],
925            ],
926            null,
927            null,
928            false,
929            "",
930            "Windows",
931            null,
932            '"Not/A)Brand";v="99", "Google Chrome";v="115", "Chromium";v="115"',
933            null
934        );
935    }
936
937    private function getPrefix(): string {
938        return 'CheckUserSimulated-';
939    }
940}
941
942$maintClass = PopulateCheckUserTablesWithSimulatedData::class;
943require_once RUN_MAINTENANCE_IF_MAIN;