diff --git a/tests/Unit/AvatarTest.php b/tests/Unit/AvatarTest.php new file mode 100644 index 0000000000..9f8de65816 --- /dev/null +++ b/tests/Unit/AvatarTest.php @@ -0,0 +1,156 @@ +setUpForum('https://example.com/forum'); + + $avatar = new Avatar(url: 'https://example.org/pictures/me.png', id_member: 1); + + $this->assertSame('https://example.org/pictures/me.png', (string) $avatar->url); + } + + /** + * An avatar chosen from the gallery is stored as a path under the avatars + * directory, not as a URL, so there is no scheme on it and no host in it. + * Saying so up front means the file is looked for by name; reading it as a + * URL instead and working back to a file from that URL's path lands outside + * the avatar directories and finds nothing, and on a forum at the root of + * its domain that path is null and stripping the board URL off it throws. + */ + #[DataProvider('boardUrls')] + public function testAGalleryAvatarIsFoundUnderTheAvatarsDirectory(string $boardurl): void + { + $this->setUpForum($boardurl); + + $avatar = new Avatar(url: 'Oxygen/beagle.png', id_member: 1); + + $this->assertSame($boardurl . '/avatars/Oxygen/beagle.png', (string) $avatar->url); + $this->assertSame('Oxygen/beagle.png', $avatar->filename); + } + + #[DataProvider('boardUrls')] + public function testAGalleryAvatarInTheRootOfTheGalleryIsFoundToo(string $boardurl): void + { + $this->setUpForum($boardurl); + + $avatar = new Avatar(url: 'default.png', id_member: 1); + + $this->assertSame($boardurl . '/avatars/default.png', (string) $avatar->url); + $this->assertSame('default.png', $avatar->filename); + } + + /** + * A gallery file that is not there falls through to the default image + * rather than producing a URL pointing at nothing. + */ + public function testAGalleryAvatarThatIsNotThereFallsBackToTheDefault(): void + { + $this->setUpForum('https://example.com'); + + $avatar = new Avatar(url: 'Oxygen/no_such_avatar.png', id_member: 1); + + $this->assertSame('https://example.com/avatars/default.png', (string) $avatar->url); + } + + /*********************** + * Public static methods + ***********************/ + + /** + * @return array + */ + public static function boardUrls(): array + { + return [ + 'a forum at the root of its domain' => ['https://example.com'], + 'a forum in a subdirectory' => ['https://example.com/forum'], + ]; + } + + /****************** + * Internal methods + ******************/ + + protected function setUp(): void + { + $this->boardurl = Config::$boardurl ?? ''; + + foreach (['avatar_url', 'avatar_directory', 'gravatarEnabled'] as $key) { + if (isset(Config::$modSettings[$key])) { + $this->backup[$key] = Config::$modSettings[$key]; + } + } + } + + /** + * PHPUnit does not reset SMF's statics between tests, so a setting left + * behind here would leak into every test that follows. + */ + protected function tearDown(): void + { + Config::$boardurl = $this->boardurl; + + foreach (['avatar_url', 'avatar_directory', 'gravatarEnabled'] as $key) { + unset(Config::$modSettings[$key]); + + if (isset($this->backup[$key])) { + Config::$modSettings[$key] = $this->backup[$key]; + } + } + + $this->backup = []; + } + + /** + * Points the avatar settings at the gallery the repository ships. + */ + private function setUpForum(string $boardurl): void + { + Config::$boardurl = $boardurl; + Config::$modSettings['avatar_url'] = $boardurl . '/avatars'; + Config::$modSettings['avatar_directory'] = Config::$boarddir . '/avatars'; + Config::$modSettings['gravatarEnabled'] = false; + } +} diff --git a/tests/Unit/MessageFormatterTest.php b/tests/Unit/MessageFormatterTest.php new file mode 100644 index 0000000000..93b054a948 --- /dev/null +++ b/tests/Unit/MessageFormatterTest.php @@ -0,0 +1,104 @@ +assertSame( + 'Hello Bob!', + MessageFormatter::formatMessage('Hello {name}!', ['name' => $this->stringable('Bob')]), + ); + } + + /** + * The braces and apostrophes in an argument are swapped for private use + * characters before the message is formatted and swapped back afterwards, + * so that a value cannot be read as MessageFormat syntax. A \Stringable is + * flattened early enough to go through that too. + */ + public function testMessageFormatSyntaxInAStringableValueIsNotInterpreted(): void + { + $this->assertSame( + "Hello it's {here}!", + MessageFormatter::formatMessage('Hello {name}!', ['name' => $this->stringable("it's {here}")]), + ); + } + + public function testTheSameStringableCanBeUsedTwiceInOneMessage(): void + { + $this->assertSame( + 'Bob and Bob', + MessageFormatter::formatMessage('{name} and {name}', ['name' => $this->stringable('Bob')]), + ); + } + + public function testAPlainStringArgumentIsUnaffected(): void + { + $this->assertSame( + 'Hello Ann!', + MessageFormatter::formatMessage('Hello {name}!', ['name' => 'Ann']), + ); + } + + public function testANumberArgumentIsStillFormattedAsANumber(): void + { + $this->assertSame( + '2 posts', + MessageFormatter::formatMessage('{count, plural, one {# post} other {# posts}}', ['count' => 2]), + ); + } + + public function testAMessageWithNoPlaceholdersComesBackUnchanged(): void + { + $this->assertSame( + 'Nothing to substitute', + MessageFormatter::formatMessage('Nothing to substitute', ['name' => $this->stringable('Bob')]), + ); + } + + /****************** + * Internal methods + ******************/ + + /** + * The simplest thing that is a string without being one. + */ + private function stringable(string $value): \Stringable + { + return new class ($value) implements \Stringable { + public function __construct(private string $value) {} + + public function __toString(): string + { + return $this->value; + } + }; + } +} diff --git a/tests/Unit/PageIndexTest.php b/tests/Unit/PageIndexTest.php new file mode 100644 index 0000000000..08b85ac679 --- /dev/null +++ b/tests/Unit/PageIndexTest.php @@ -0,0 +1,179 @@ +assertStringContainsString( + '1', + (string) $page_index, + ); + + $this->assertStringNotContainsString('current_page', (string) $page_index); + } + + /** + * Nothing was navigated away from, so there is nowhere to go back to and + * nothing to go on to. + */ + public function testANegativeStartShowsNeitherPreviousNorNextLinks(): void + { + $start = -1; + $page_index = new PageIndex('https://example.com/index.php?board=1.0', $start, 100, 20); + + $this->assertStringNotContainsString('previous_page', (string) $page_index); + $this->assertStringNotContainsString('next_page', (string) $page_index); + } + + /** + * The string is built in __toString() rather than the constructor so that + * it reflects any property the caller changed in between, which means it + * has to survive being asked more than once. + */ + public function testAskingTwiceGivesTheSameAnswer(): void + { + $start = -1; + $page_index = new PageIndex('https://example.com/index.php?board=1.0', $start, 100, 20); + + $this->assertSame((string) $page_index, (string) $page_index); + } + + public function testTheStartValueIsClampedAndHandedBackToTheCaller(): void + { + $start = -1; + new PageIndex('https://example.com/index.php?board=1.0', $start, 100, 20); + + $this->assertSame(0, $start); + } + + /** + * The control. A start that names a real page marks that page as the + * current one and links the pages either side of it. + */ + public function testAStartThatNamesAPageMarksThatPageAsCurrent(): void + { + $start = 40; + $page_index = new PageIndex('https://example.com/index.php?board=1.0', $start, 100, 20); + + $this->assertStringContainsString('3', (string) $page_index); + $this->assertStringContainsString('previous_page', (string) $page_index); + $this->assertStringContainsString('next_page', (string) $page_index); + } + + /** + * A start in the middle of a page belongs to that page, and the caller is + * told which page that turned out to be. + */ + public function testAStartInTheMiddleOfAPageIsMovedToItsStart(): void + { + $start = 45; + $page_index = new PageIndex('https://example.com/index.php?board=1.0', $start, 100, 20); + + $this->assertSame(40, $start); + $this->assertStringContainsString('3', (string) $page_index); + } + + /** + * A start past the end is clamped to the last page, and that is a real + * page, so it is marked rather than linked. + */ + public function testAStartPastTheEndLandsOnTheLastPage(): void + { + $start = 500; + $page_index = new PageIndex('https://example.com/index.php?board=1.0', $start, 100, 20); + + $this->assertSame(80, $start); + $this->assertStringContainsString('5', (string) $page_index); + $this->assertStringNotContainsString('next_page', (string) $page_index); + } + + /****************** + * Internal methods + ******************/ + + protected function setUp(): void + { + foreach (['compactTopicPagesEnable', 'compactTopicPagesContiguous'] as $key) { + if (isset(Config::$modSettings[$key])) { + $this->backup[$key] = Config::$modSettings[$key]; + } + + unset(Config::$modSettings[$key]); + } + + $this->had_current_page = isset(Utils::$context['current_page']); + } + + /** + * PHPUnit does not reset SMF's statics between tests, and the constructor + * fills in Utils::$context['current_page'] when nothing else has, so both + * that and the settings have to go back the way they were. + */ + protected function tearDown(): void + { + foreach (['compactTopicPagesEnable', 'compactTopicPagesContiguous'] as $key) { + unset(Config::$modSettings[$key]); + + if (isset($this->backup[$key])) { + Config::$modSettings[$key] = $this->backup[$key]; + } + } + + if (!$this->had_current_page) { + unset(Utils::$context['current_page']); + } + + $this->backup = []; + } +} diff --git a/tests/Unit/SpoofDetectorTest.php b/tests/Unit/SpoofDetectorTest.php new file mode 100644 index 0000000000..360dff2aab --- /dev/null +++ b/tests/Unit/SpoofDetectorTest.php @@ -0,0 +1,183 @@ +assertTrue(SpoofDetector::checkReservedName('Admin')); + $this->assertTrue(SpoofDetector::checkReservedName('Webmaster')); + $this->assertTrue(SpoofDetector::checkReservedName('Guest')); + $this->assertTrue(SpoofDetector::checkReservedName('root')); + } + + /** + * A list an admin edited by hand arrives with real line breaks, whichever + * kind their browser sent. + */ + public function testAListWrittenWithRealLineBreaksStillWorks(): void + { + Config::$modSettings['reserveNames'] = "Admin\nWebmaster"; + + $this->assertTrue(SpoofDetector::checkReservedName('Webmaster')); + + Config::$modSettings['reserveNames'] = "Admin\r\nWebmaster"; + + $this->assertTrue(SpoofDetector::checkReservedName('Webmaster')); + } + + public function testANameNobodyReservedIsAllowed(): void + { + Config::$modSettings['reserveNames'] = 'Admin\nWebmaster\nGuest\nroot'; + + $this->assertFalse(SpoofDetector::checkReservedName('Somebody')); + } + + public function testAnEmptyListReservesNothing(): void + { + Config::$modSettings['reserveNames'] = ''; + + $this->assertFalse(SpoofDetector::checkReservedName('Admin')); + } + + /** + * The point of the class: a name is compared by its skeleton, so a + * character that merely looks like the one on the list counts as being on + * the list. U+0410 is Cyrillic capital A. + */ + public function testACharacterThatMerelyLooksTheSameIsStillReserved(): void + { + Config::$modSettings['reserveNames'] = 'Admin'; + + $this->assertTrue(SpoofDetector::checkReservedName("\u{0410}dmin")); + } + + /** + * The admin's list and the name being checked are both decoded first, so + * neither side can hide behind an entity. + */ + public function testAnEntityIsDecodedBeforeComparison(): void + { + Config::$modSettings['reserveNames'] = 'Webmaster'; + + $this->assertTrue(SpoofDetector::checkReservedName('Webmaster')); + } + + #[DataProvider('reserveWordCases')] + public function testReserveWordDecidesWhetherPartOfANameCounts(int $reserve_word, string $name, bool $expected): void + { + Config::$modSettings['reserveNames'] = 'Admin'; + Config::$modSettings['reserveWord'] = $reserve_word; + + $this->assertSame($expected, SpoofDetector::checkReservedName($name)); + } + + #[DataProvider('reserveCaseCases')] + public function testReserveCaseDecidesWhetherTheCaseHasToMatch(int $reserve_case, string $name, bool $expected): void + { + Config::$modSettings['reserveNames'] = 'Admin'; + Config::$modSettings['reserveCase'] = $reserve_case; + + $this->assertSame($expected, SpoofDetector::checkReservedName($name)); + } + + /*********************** + * Public static methods + ***********************/ + + /** + * @return array + */ + public static function reserveWordCases(): array + { + return [ + 'a reserved name inside a longer one, matching anywhere' => [0, 'SuperAdmin', true], + 'a reserved name inside a longer one, whole names only' => [1, 'SuperAdmin', false], + 'the reserved name itself, matching anywhere' => [0, 'Admin', true], + 'the reserved name itself, whole names only' => [1, 'Admin', true], + ]; + } + + /** + * @return array + */ + public static function reserveCaseCases(): array + { + return [ + 'a different case, compared caselessly' => [0, 'admin', true], + 'a different case, compared exactly' => [1, 'admin', false], + 'the same case, compared caselessly' => [0, 'Admin', true], + 'the same case, compared exactly' => [1, 'Admin', true], + ]; + } + + /****************** + * Internal methods + ******************/ + + protected function setUp(): void + { + foreach (['reserveNames', 'reserveCase', 'reserveWord'] as $key) { + if (isset(Config::$modSettings[$key])) { + $this->backup[$key] = Config::$modSettings[$key]; + } + } + } + + /** + * PHPUnit does not reset SMF's statics between tests, so a setting left + * behind here would leak into every test that follows. + */ + protected function tearDown(): void + { + foreach (['reserveNames', 'reserveCase', 'reserveWord'] as $key) { + unset(Config::$modSettings[$key]); + + if (isset($this->backup[$key])) { + Config::$modSettings[$key] = $this->backup[$key]; + } + } + + $this->backup = []; + } +}