ユニットテスト
守矢諏訪子 <suwako@076.moe>、2026-11-10 20:55
コミット 7208562c62eb49b16ef4c0b1f68b20aac9bd6367
・親 f6cdbde
・ファイルを見る
14個のファイル、+1775 -156
| @@ -175,6 +175,9 @@ | ||
| 175 | 175 | call_user_func($after); |
| 176 | 176 | } |
| 177 | 177 | } catch (\Throwable $e) { |
| 178 | + // 以前はerrorCountを増やさなかったので、「全てのテストに合格しました!」と表示された | |
| 179 | + $this->errorCount++; | |
| 180 | + $this->currentTest = ''; | |
| 178 | 181 | $this->recordError( |
| 179 | 182 | "テストケースのセットアップ/ティアダウンでエラー: ".$e->getMessage(), |
| 180 | 183 | $e->getTraceAsString()); |
| @@ -0,0 +1,127 @@ | ||
| 1 | +<?php | |
| 2 | +/************************************************************* | |
| 3 | +# 076 License | |
| 4 | + | |
| 5 | +Copyright (c) テクニカル諏訪子 | |
| 6 | + | |
| 7 | +Permission is hereby granted to any person obtaining a copy of the software | |
| 8 | +Little Beast (the "Software") to use, modify, merge, copy, publish, distribute, | |
| 9 | +sublicense, and/or sell copies of the Software, subject to the following conditions: | |
| 10 | + | |
| 11 | + 1. **Origin Attribution**: | |
| 12 | + - You must not misrepresent the origin of the Software; you must not claim | |
| 13 | + you created the original Software. | |
| 14 | + - If the Software is used in a product, you must either: | |
| 15 | + a. Provide clear attribution in the product's documentation, user interface, | |
| 16 | + or other visible areas, **OR** | |
| 17 | + b. Pay the original developers a fee they specify in writing. | |
| 18 | + 2. **Usage Restriction**: | |
| 19 | + - The Software, or any derivative works, dependencies, or libraries | |
| 20 | + incorporating it, must not be used for censorship or to suppress freedom of | |
| 21 | + speech, expression, or creativity. Prohibited uses include, but are not | |
| 22 | + limited to: | |
| 23 | + - Censorship of so-called "hate speech", visuals, non-mainstream opinions, | |
| 24 | + ideas, or objective reality. | |
| 25 | + - Tools or systems designed to restrict access to information or | |
| 26 | + artistic works. | |
| 27 | + 3. **Notice Preservation**: | |
| 28 | + - This license and the above copyright notice must remain intact in all copies | |
| 29 | + of the source code. | |
| 30 | + | |
| 31 | +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, | |
| 32 | +INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A | |
| 33 | +PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT | |
| 34 | +HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF | |
| 35 | +CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE | |
| 36 | +OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. | |
| 37 | + */ | |
| 38 | +namespace Std\Test\Helper; | |
| 39 | + | |
| 40 | +/** | |
| 41 | + * テスト用の偽物のサーバー(別のPHPプロセスで動かす) | |
| 42 | + * | |
| 43 | + * http-server.php(HTTP)とsmtp-server.php(SMTP)を127.0.0.1の空いているポートで起動する。 | |
| 44 | + * ネットワークに繋がっていなくてもCurl・Openprovider・Mailerをテスト出来る様に。 | |
| 45 | + * | |
| 46 | + * 例: | |
| 47 | + * $srv = MockServer::start('http-server.php'); | |
| 48 | + * $curl = new Curl($srv->url('/echo')); | |
| 49 | + * $srv->stop(); | |
| 50 | + */ | |
| 51 | +class MockServer { | |
| 52 | + public int $port; | |
| 53 | + /** @var resource */ | |
| 54 | + private $proc; | |
| 55 | + private array $pipes = []; | |
| 56 | + | |
| 57 | + private function __construct() {} | |
| 58 | + | |
| 59 | + /** | |
| 60 | + * サーバーを起動する | |
| 61 | + * | |
| 62 | + * @param string $script このディレクトリのスクリプト名 | |
| 63 | + * @param array $args スクリプトに渡す追加の引数 | |
| 64 | + * @return self|null 起動出来なかった場合はnull(proc_openが無効等) | |
| 65 | + */ | |
| 66 | + public static function start(string $script, array $args = []): ?self { | |
| 67 | + if (!function_exists('proc_open')) return null; | |
| 68 | + | |
| 69 | + // 空いているポートを探す | |
| 70 | + $probe = @stream_socket_server('tcp://127.0.0.1:0', $errno, $errstr); | |
| 71 | + if (!$probe) return null; | |
| 72 | + $port = (int)substr(strrchr(stream_socket_get_name($probe, false), ':'), 1); | |
| 73 | + fclose($probe); | |
| 74 | + | |
| 75 | + $cmd = array_merge([PHP_BINARY, __DIR__.'/'.$script, (string)$port], array_map('strval', $args)); | |
| 76 | + $spec = [0 => ['pipe', 'r'], 1 => ['pipe', 'w'], 2 => ['pipe', 'w']]; | |
| 77 | + $proc = @proc_open($cmd, $spec, $pipes); | |
| 78 | + if (!is_resource($proc)) return null; | |
| 79 | + | |
| 80 | + // 「ready」が出力されるまで待つ(起動に失敗したらfalse) | |
| 81 | + $line = fgets($pipes[1]); | |
| 82 | + if (trim((string)$line) !== 'ready') { | |
| 83 | + proc_terminate($proc); | |
| 84 | + proc_close($proc); | |
| 85 | + return null; | |
| 86 | + } | |
| 87 | + | |
| 88 | + $srv = new self(); | |
| 89 | + $srv->port = $port; | |
| 90 | + $srv->proc = $proc; | |
| 91 | + $srv->pipes = $pipes; | |
| 92 | + return $srv; | |
| 93 | + } | |
| 94 | + | |
| 95 | + /** | |
| 96 | + * このサーバーのURL | |
| 97 | + * | |
| 98 | + * @param string $path パス | |
| 99 | + * @param string $host 127.0.0.1又はlocalhost(別のオリジンとして扱う為) | |
| 100 | + * @return string | |
| 101 | + */ | |
| 102 | + public function url(string $path = '/', string $host = '127.0.0.1'): string { | |
| 103 | + return "http://{$host}:{$this->port}{$path}"; | |
| 104 | + } | |
| 105 | + | |
| 106 | + /** | |
| 107 | + * サーバーを止める | |
| 108 | + * | |
| 109 | + * @return void | |
| 110 | + */ | |
| 111 | + public function stop(): void { | |
| 112 | + if (!is_resource($this->proc)) return; | |
| 113 | + // 終了のリクエストを送る(Windowsではproc_terminateで子プロセスが残る事がある) | |
| 114 | + $c = @stream_socket_client("tcp://127.0.0.1:{$this->port}", $errno, $errstr, 1); | |
| 115 | + if ($c) { | |
| 116 | + fwrite($c, "QUIT /__quit HTTP/1.0\r\n\r\n"); | |
| 117 | + fclose($c); | |
| 118 | + } | |
| 119 | + foreach ($this->pipes as $p) if (is_resource($p)) fclose($p); | |
| 120 | + proc_terminate($this->proc); | |
| 121 | + proc_close($this->proc); | |
| 122 | + } | |
| 123 | + | |
| 124 | + public function __destruct() { | |
| 125 | + $this->stop(); | |
| 126 | + } | |
| 127 | +} |
| @@ -0,0 +1,146 @@ | ||
| 1 | +<?php | |
| 2 | +/************************************************************* | |
| 3 | +# 076 License | |
| 4 | + | |
| 5 | +Copyright (c) テクニカル諏訪子 | |
| 6 | + | |
| 7 | +Permission is hereby granted to any person obtaining a copy of the software | |
| 8 | +Little Beast (the "Software") to use, modify, merge, copy, publish, distribute, | |
| 9 | +sublicense, and/or sell copies of the Software, subject to the following conditions: | |
| 10 | + | |
| 11 | + 1. **Origin Attribution**: | |
| 12 | + - You must not misrepresent the origin of the Software; you must not claim | |
| 13 | + you created the original Software. | |
| 14 | + - If the Software is used in a product, you must either: | |
| 15 | + a. Provide clear attribution in the product's documentation, user interface, | |
| 16 | + or other visible areas, **OR** | |
| 17 | + b. Pay the original developers a fee they specify in writing. | |
| 18 | + 2. **Usage Restriction**: | |
| 19 | + - The Software, or any derivative works, dependencies, or libraries | |
| 20 | + incorporating it, must not be used for censorship or to suppress freedom of | |
| 21 | + speech, expression, or creativity. Prohibited uses include, but are not | |
| 22 | + limited to: | |
| 23 | + - Censorship of so-called "hate speech", visuals, non-mainstream opinions, | |
| 24 | + ideas, or objective reality. | |
| 25 | + - Tools or systems designed to restrict access to information or | |
| 26 | + artistic works. | |
| 27 | + 3. **Notice Preservation**: | |
| 28 | + - This license and the above copyright notice must remain intact in all copies | |
| 29 | + of the source code. | |
| 30 | + | |
| 31 | +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, | |
| 32 | +INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A | |
| 33 | +PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT | |
| 34 | +HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF | |
| 35 | +CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE | |
| 36 | +OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. | |
| 37 | + */ | |
| 38 | +// テスト用の偽物のHTTPサーバー(MockServerが起動する)。使い方:php http-server.php ポート | |
| 39 | +// 1回に1つの接続を順番に処理する。 | |
| 40 | + | |
| 41 | +$port = (int)($argv[1] ?? 0); | |
| 42 | +$server = @stream_socket_server("tcp://127.0.0.1:{$port}", $errno, $errstr); | |
| 43 | +if (!$server) { | |
| 44 | + fwrite(STDOUT, "error {$errstr}\n"); | |
| 45 | + exit(1); | |
| 46 | +} | |
| 47 | +fwrite(STDOUT, "ready\n"); | |
| 48 | +fflush(STDOUT); | |
| 49 | + | |
| 50 | +$count = []; | |
| 51 | + | |
| 52 | +/** 生のHTTPレスポンス */ | |
| 53 | +function response(int $code, string $body, array $headers = []): string { | |
| 54 | + $reason = [200 => 'OK', 301 => 'Moved Permanently', 302 => 'Found', 303 => 'See Other', | |
| 55 | + 401 => 'Unauthorized', 404 => 'Not Found', 502 => 'Bad Gateway'][$code] ?? 'Status'; | |
| 56 | + $out = "HTTP/1.1 {$code} {$reason}\r\n"; | |
| 57 | + $headers += ['Content-Type' => 'application/json', 'Content-Length' => strlen($body), 'Connection' => 'close']; | |
| 58 | + foreach ($headers as $k => $v) $out .= "{$k}: {$v}\r\n"; | |
| 59 | + return $out."\r\n".$body; | |
| 60 | +} | |
| 61 | + | |
| 62 | +function json(int $code, array $data): string { | |
| 63 | + return response($code, json_encode($data, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES)); | |
| 64 | +} | |
| 65 | + | |
| 66 | +while (true) { | |
| 67 | + $c = @stream_socket_accept($server, 60); | |
| 68 | + if (!$c) break; // 60秒何も来なければ終了(テストが落ちても残らない様に) | |
| 69 | + stream_set_timeout($c, 5); | |
| 70 | + | |
| 71 | + // リクエストの読み込み | |
| 72 | + $head = ''; | |
| 73 | + while (!str_contains($head, "\r\n\r\n") && ($line = fgets($c)) !== false) $head .= $line; | |
| 74 | + [$reqLine] = explode("\r\n", $head, 2); | |
| 75 | + $parts = explode(' ', $reqLine); | |
| 76 | + $method = $parts[0] ?? ''; | |
| 77 | + $target = $parts[1] ?? '/'; | |
| 78 | + $headers = []; | |
| 79 | + foreach (array_slice(explode("\r\n", trim($head)), 1) as $h) { | |
| 80 | + if (!str_contains($h, ':')) continue; | |
| 81 | + [$k, $v] = explode(':', $h, 2); | |
| 82 | + $headers[strtolower(trim($k))] = trim($v); | |
| 83 | + } | |
| 84 | + $body = ''; | |
| 85 | + $len = (int)($headers['content-length'] ?? 0); | |
| 86 | + while (strlen($body) < $len && !feof($c)) { | |
| 87 | + $chunk = fread($c, $len - strlen($body)); | |
| 88 | + if ($chunk === false || $chunk === '') break; | |
| 89 | + $body .= $chunk; | |
| 90 | + } | |
| 91 | + | |
| 92 | + $path = parse_url($target, PHP_URL_PATH) ?: '/'; | |
| 93 | + parse_str((string)parse_url($target, PHP_URL_QUERY), $query); | |
| 94 | + $count[$path] = ($count[$path] ?? 0) + 1; | |
| 95 | + $req = ['method' => $method, 'target' => $target, 'headers' => $headers, 'body' => $body]; | |
| 96 | + | |
| 97 | + if ($path === '/__quit') { | |
| 98 | + fclose($c); | |
| 99 | + break; | |
| 100 | + } | |
| 101 | + | |
| 102 | + $bearer = ($headers['authorization'] ?? '') === 'Bearer tok123'; | |
| 103 | + | |
| 104 | + $out = match (true) { | |
| 105 | + // Curl用 | |
| 106 | + $path === '/echo' => json(200, $req), | |
| 107 | + $path === '/count' => json(200, ['count' => $count[$query['path'] ?? ''] ?? 0]), | |
| 108 | + $path === '/chunked' => "HTTP/1.1 200 OK\r\nTransfer-Encoding: chunked\r\nConnection: close\r\n\r\n" | |
| 109 | + ."7\r\nHello, \r\n6\r\nworld!\r\n0\r\n\r\n", | |
| 110 | + $path === '/redirect' => response(302, '', ['Location' => $query['to'] ?? '/echo']), | |
| 111 | + $path === '/redirect-303' => response(303, '', ['Location' => '/echo']), | |
| 112 | + $path === '/redirect-301' => response(301, '', ['Location' => 'echo?from=301']), | |
| 113 | + $path === '/loop' => response(302, '', ['Location' => '/loop']), | |
| 114 | + $path === '/big' => response(200, str_repeat('x', 200000), ['Content-Type' => 'text/plain']), | |
| 115 | + $path === '/slow' => (function() { sleep(3); return response(200, 'late'); })(), | |
| 116 | + $path === '/missing' => json(404, ['desc' => 'Not found']), | |
| 117 | + | |
| 118 | + // Openprovider用(/v1beta) | |
| 119 | + $path === '/v1beta/auth/login' => (function() use ($body) { | |
| 120 | + $in = json_decode($body, true); | |
| 121 | + return is_array($in) && isset($in['username'], $in['password']) | |
| 122 | + ? json(200, ['code' => 0, 'data' => ['token' => 'tok123', 'reseller_id' => 42]]) | |
| 123 | + : json(401, ['code' => 196, 'desc' => 'Authentication/Authorization Failed']); | |
| 124 | + })(), | |
| 125 | + $path === '/bad/v1beta/auth/login' => json(401, ['code' => 196, 'desc' => 'Authentication/Authorization Failed']), | |
| 126 | + $path === '/v1beta/domains/check' && $bearer => (function() use ($body) { | |
| 127 | + $in = json_decode($body, true); | |
| 128 | + $results = []; | |
| 129 | + foreach ($in['domains'] ?? [] as $d) { | |
| 130 | + $name = "{$d['name']}.{$d['extension']}"; | |
| 131 | + $results[] = $name === '076.moe' ? ['domain' => $name, 'status' => 'active', 'reason' => 'In use'] | |
| 132 | + : ['domain' => $name, 'status' => 'free']; | |
| 133 | + } | |
| 134 | + return json(200, ['code' => 0, 'data' => ['results' => $results]]); | |
| 135 | + })(), | |
| 136 | + $path === '/v1beta/domains' && $method === 'GET' && $bearer | |
| 137 | + => json(200, ['code' => 0, 'data' => ['results' => [['id' => 1, 'domain' => ['name' => '076', 'extension' => 'moe']]], 'total' => 1]]), | |
| 138 | + $path === '/v1beta/gateway' => response(502, '<html>Bad Gateway</html>', ['Content-Type' => 'text/html']), | |
| 139 | + str_starts_with($path, '/v1beta/') && !$bearer => json(401, ['code' => 196, 'desc' => 'Authentication/Authorization Failed']), | |
| 140 | + | |
| 141 | + default => json(404, ['desc' => "No route: {$path}"]), | |
| 142 | + }; | |
| 143 | + | |
| 144 | + @fwrite($c, $out); | |
| 145 | + fclose($c); | |
| 146 | +} |
| @@ -0,0 +1,105 @@ | ||
| 1 | +<?php | |
| 2 | +/************************************************************* | |
| 3 | +# 076 License | |
| 4 | + | |
| 5 | +Copyright (c) テクニカル諏訪子 | |
| 6 | + | |
| 7 | +Permission is hereby granted to any person obtaining a copy of the software | |
| 8 | +Little Beast (the "Software") to use, modify, merge, copy, publish, distribute, | |
| 9 | +sublicense, and/or sell copies of the Software, subject to the following conditions: | |
| 10 | + | |
| 11 | + 1. **Origin Attribution**: | |
| 12 | + - You must not misrepresent the origin of the Software; you must not claim | |
| 13 | + you created the original Software. | |
| 14 | + - If the Software is used in a product, you must either: | |
| 15 | + a. Provide clear attribution in the product's documentation, user interface, | |
| 16 | + or other visible areas, **OR** | |
| 17 | + b. Pay the original developers a fee they specify in writing. | |
| 18 | + 2. **Usage Restriction**: | |
| 19 | + - The Software, or any derivative works, dependencies, or libraries | |
| 20 | + incorporating it, must not be used for censorship or to suppress freedom of | |
| 21 | + speech, expression, or creativity. Prohibited uses include, but are not | |
| 22 | + limited to: | |
| 23 | + - Censorship of so-called "hate speech", visuals, non-mainstream opinions, | |
| 24 | + ideas, or objective reality. | |
| 25 | + - Tools or systems designed to restrict access to information or | |
| 26 | + artistic works. | |
| 27 | + 3. **Notice Preservation**: | |
| 28 | + - This license and the above copyright notice must remain intact in all copies | |
| 29 | + of the source code. | |
| 30 | + | |
| 31 | +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, | |
| 32 | +INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A | |
| 33 | +PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT | |
| 34 | +HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF | |
| 35 | +CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE | |
| 36 | +OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. | |
| 37 | + */ | |
| 38 | +// テスト用の偽物のSMTPサーバー(MockServerが起動する)。使い方:php smtp-server.php ポート 記録ファイル | |
| 39 | +// 受け取ったコマンドとメール本文を記録ファイルに書く。 | |
| 40 | + | |
| 41 | +$port = (int)($argv[1] ?? 0); | |
| 42 | +$log = $argv[2] ?? ''; | |
| 43 | +$server = @stream_socket_server("tcp://127.0.0.1:{$port}", $errno, $errstr); | |
| 44 | +if (!$server || $log === '') { | |
| 45 | + fwrite(STDOUT, "error {$errstr}\n"); | |
| 46 | + exit(1); | |
| 47 | +} | |
| 48 | +fwrite(STDOUT, "ready\n"); | |
| 49 | +fflush(STDOUT); | |
| 50 | + | |
| 51 | +while (true) { | |
| 52 | + $c = @stream_socket_accept($server, 60); | |
| 53 | + if (!$c) break; | |
| 54 | + stream_set_timeout($c, 5); | |
| 55 | + | |
| 56 | + $first = true; | |
| 57 | + $quit = false; | |
| 58 | + fwrite($c, "220 mock ESMTP\r\n"); | |
| 59 | + $auth = 0; | |
| 60 | + while (($line = fgets($c)) !== false) { | |
| 61 | + if ($first && str_starts_with($line, 'QUIT /__quit')) { | |
| 62 | + $quit = true; | |
| 63 | + break; | |
| 64 | + } | |
| 65 | + $first = false; | |
| 66 | + file_put_contents($log, "C: {$line}", FILE_APPEND); | |
| 67 | + $cmd = strtoupper(substr(trim($line), 0, 4)); | |
| 68 | + | |
| 69 | + if ($auth > 0) { | |
| 70 | + $auth--; | |
| 71 | + fwrite($c, $auth > 0 ? "334 UGFzc3dvcmQ6\r\n" : "235 2.7.0 OK\r\n"); | |
| 72 | + continue; | |
| 73 | + } | |
| 74 | + | |
| 75 | + switch (true) { | |
| 76 | + case $cmd === 'EHLO': | |
| 77 | + fwrite($c, "250-mock\r\n250-8BITMIME\r\n250 AUTH LOGIN\r\n"); | |
| 78 | + break; | |
| 79 | + case str_starts_with(strtoupper($line), 'AUTH LOGIN'): | |
| 80 | + $auth = 2; | |
| 81 | + fwrite($c, "334 VXNlcm5hbWU6\r\n"); | |
| 82 | + break; | |
| 83 | + case $cmd === 'MAIL': | |
| 84 | + fwrite($c, "250 OK\r\n"); | |
| 85 | + break; | |
| 86 | + case $cmd === 'RCPT': | |
| 87 | + fwrite($c, str_contains($line, 'reject') ? "550 No such user\r\n" : "250 OK\r\n"); | |
| 88 | + break; | |
| 89 | + case $cmd === 'DATA': | |
| 90 | + fwrite($c, "354 Go ahead\r\n"); | |
| 91 | + $data = ''; | |
| 92 | + while (($l = fgets($c)) !== false && $l !== ".\r\n") $data .= $l; | |
| 93 | + file_put_contents($log, "DATA:\n{$data}END DATA\n", FILE_APPEND); | |
| 94 | + fwrite($c, "250 Queued\r\n"); | |
| 95 | + break; | |
| 96 | + case $cmd === 'QUIT': | |
| 97 | + fwrite($c, "221 Bye\r\n"); | |
| 98 | + break 2; | |
| 99 | + default: | |
| 100 | + fwrite($c, "502 Unknown\r\n"); | |
| 101 | + } | |
| 102 | + } | |
| 103 | + fclose($c); | |
| 104 | + if ($quit) break; | |
| 105 | +} |
| @@ -43,73 +43,257 @@ | ||
| 43 | 43 | use Std\Lib\Activitypub; |
| 44 | 44 | use Site\Controller\BlogPost; |
| 45 | 45 | |
| 46 | -if (ACTIVITYPUB_ENABLED) { | |
| 47 | - $test = new Tester([ | |
| 48 | - 'colorOutput' => true, | |
| 49 | - 'verboseOutput' => true | |
| 50 | - ]); | |
| 46 | +$test = new Tester([ | |
| 47 | + 'colorOutput' => true, | |
| 48 | + 'verboseOutput' => true | |
| 49 | +]); | |
| 51 | 50 | |
| 52 | - /* | |
| 53 | - postInbox(array $activity): void | |
| 54 | - update(): string | |
| 55 | - sendActorUpdate(array $params): void | |
| 56 | - */ | |
| 51 | +$followers = tempnam(sys_get_temp_dir(), 'lbfollow_'); | |
| 52 | +$key = function_exists('openssl_pkey_new') | |
| 53 | + ? @openssl_pkey_new(['private_key_bits' => 2048, 'private_key_type' => OPENSSL_KEYTYPE_RSA]) : false; | |
| 57 | 54 | |
| 58 | - $test->describe('ActivityPub情報の受け取り', function($test): void { | |
| 59 | - $test->it('このインスタンスのユーザーを受け取れるはず', function($test): void { | |
| 60 | - $ap = new Activitypub(); | |
| 61 | - $res = $ap->getActor(); | |
| 62 | - $test->assertNotNull($res); | |
| 63 | - $json = json_decode($res, true); | |
| 55 | +// 外部との通信だけを偽物にする | |
| 56 | +$make = fn(array $posts = []) => new class($posts, $followers, $key) extends Activitypub { | |
| 57 | + public array $sent = []; | |
| 58 | + public array $remote = []; | |
| 59 | + public function __construct(array $posts, private string $file, private mixed $key) { | |
| 60 | + parent::__construct($posts); | |
| 61 | + } | |
| 62 | + protected function followersFile(): string { return $this->file; } | |
| 63 | + protected function getInboxFromActor(string $actor): ?string { return $this->remote[$actor]['inbox'] ?? null; } | |
| 64 | + protected function fetchRemoteJson(string $url): ?array { return $this->remote[$url] ?? null; } | |
| 65 | + protected function sendActivity(string $inboxUrl, array $activity): bool { | |
| 66 | + $this->sent[] = [$inboxUrl, $activity]; | |
| 67 | + return true; | |
| 68 | + } | |
| 69 | + public function sign(string $url, string $body, string $method = 'post'): ?array { | |
| 70 | + return $this->signRequest($url, $body, $this->key, $method); | |
| 71 | + } | |
| 72 | +}; | |
| 64 | 73 | |
| 65 | - $expect = 'Person'; | |
| 66 | - $test->assertEquals($json['type'], $expect); | |
| 74 | +$test->describe('ActivityPub', function($test) use ($make, $followers, $key): void { | |
| 75 | + if (!ACTIVITYPUB_ENABLED) { | |
| 76 | + $test->skipAll('ACTIVITYPUB_ENABLEDが無効'); | |
| 77 | + } | |
| 67 | 78 | |
| 68 | - $expect = 'http://127.0.0.1:8000'; | |
| 69 | - $test->assertEquals($json['url'], $expect); | |
| 79 | + $posts = ACTIVITYPUB_ENABLED ? (new BlogPost())->getPosts('/blog/', null) : []; | |
| 80 | + $finger = ACTIVITYPUB_ENABLED ? json_decode($make()->getWebfinger(), true) : []; | |
| 81 | + $domain = substr($finger['links'][0]['href'] ?? '', 0, -strlen('/ap/actor')); | |
| 82 | + $feature = 'a8c04518-4181-4ec6-9ef0-3f88f23b84b6'; // /blog/feature-test | |
| 83 | + $member = 'd49f60c7-6d85-4263-9d3f-c20025f2fa0d'; // /blog/memberonly-post | |
| 84 | + $remoteActor = 'https://1.1.1.1/users/alice'; | |
| 70 | 85 | |
| 71 | - $expect = 'http://127.0.0.1:8000/ap/actor'; | |
| 72 | - $test->assertEquals($json['id'], $expect); | |
| 86 | + $test->it('WebFingerを受け取れるはず', function($test) use ($finger, $domain): void { | |
| 87 | + $host = parse_url($domain, PHP_URL_HOST); | |
| 88 | + $test->assertEquals("acct:".FEDIINFO['actor']."@{$host}", $finger['subject']); | |
| 89 | + $test->assertEquals('application/activity+json', $finger['links'][0]['type']); | |
| 90 | + }); | |
| 73 | 91 | |
| 74 | - $expect = 'http://127.0.0.1:8000/ap/inbox'; | |
| 75 | - $test->assertEquals($json['inbox'], $expect); | |
| 92 | + $test->it('このインスタンスのユーザーを受け取れるはず', function($test) use ($make, $domain): void { | |
| 93 | + try { | |
| 94 | + $json = json_decode($make()->getActor(), true); | |
| 95 | + } catch (\Exception $e) { | |
| 96 | + $test->assertStringContains('公開鍵', $e->getMessage()); | |
| 97 | + return; // 公開鍵が読めない環境 | |
| 98 | + } | |
| 99 | + $test->assertEquals('Person', $json['type']); | |
| 100 | + $test->assertEquals("{$domain}/ap/actor", $json['id']); | |
| 101 | + $test->assertEquals("{$domain}/ap/inbox", $json['inbox']); | |
| 102 | + $test->assertEquals("{$domain}/ap/outbox", $json['outbox']); | |
| 103 | + $test->assertEquals("{$domain}/ap/followers", $json['followers']); | |
| 104 | + $test->assertEquals("{$domain}/ap/actor#main-key", $json['publicKey']['id']); | |
| 105 | + }); | |
| 76 | 106 | |
| 77 | - $expect = 'http://127.0.0.1:8000/ap/outbox'; | |
| 78 | - $test->assertEquals($json['outbox'], $expect); | |
| 107 | + $test->it('公開投稿のアクティビティを受け取れるはず', function($test) use ($make, $posts, $feature, $domain): void { | |
| 108 | + $json = json_decode($make($posts)->getActivity($feature), true); | |
| 109 | + $test->assertEquals('Create', $json['type']); | |
| 110 | + $test->assertEquals("{$domain}/ap/activities/create/{$feature}", $json['id']); | |
| 111 | + $test->assertEquals('【マークダウン】関数', $json['object']['name']); | |
| 112 | + $test->assertEquals('https://www.w3.org/ns/activitystreams#Public', $json['object']['to'][0]); | |
| 113 | + // 投稿の日付(PHPのタイムゾーン)をUTCにする | |
| 114 | + $test->assertEquals(gmdate('Y-m-d\TH:i:s\Z', strtotime('2025-11-07 20:33:42')), $json['published']); | |
| 115 | + // タグと添付はNoteに付ける | |
| 116 | + $test->assertEquals(['type' => 'Hashtag', 'name' => '#test'], $json['object']['tag'][0]); | |
| 117 | + $test->assertFalse(isset($json['tag'])); | |
| 118 | + $test->assertStringContains('/blog/feature-test', $json['object']['url']); | |
| 79 | 119 | |
| 80 | - $expect = 'http://127.0.0.1:8000/ap/followers'; | |
| 81 | - $test->assertEquals($json['followers'], $expect); | |
| 120 | + if (is_file(ROOT.'/public/static/article/o_53803618dc1691.28179609-orig.jpg')) { | |
| 121 | + $test->assertEquals('zBbw7K7L1dEwr6htZX5JevRv3oLtHwXQU4o5jT8jn9RKF', $json['object']['attachment'][0]['digestMultibase']); | |
| 122 | + $test->assertEquals("{$domain}/static/article/o_53803618dc1691.28179609-orig.jpg", $json['object']['attachment'][0]['url']); | |
| 123 | + $test->assertEquals('image/jpeg', $json['object']['attachment'][0]['mediaType']); | |
| 124 | + } | |
| 125 | + }); | |
| 82 | 126 | |
| 83 | - $expect = 'http://127.0.0.1:8000/ap/following'; | |
| 84 | - $test->assertEquals($json['following'], $expect); | |
| 85 | - }); | |
| 86 | - | |
| 87 | - $test->it('アクティビティを受け取れるはず', function($test): void { | |
| 88 | - $blog = new BlogPost; | |
| 89 | - $posts = $blog->getPosts('/blog/', null); | |
| 90 | - $ap = new Activitypub($posts); | |
| 91 | - $res = $ap->getActivity('a8c04518-4181-4ec6-9ef0-3f88f23b84b6'); // /blog/feature-test | |
| 92 | - $test->assertNotNull($res); | |
| 93 | - $json = json_decode($res, true); | |
| 127 | + $test->it('会員限定・存在しない投稿は返さないはず', function($test) use ($make, $posts, $member): void { | |
| 128 | + $ap = $make($posts); | |
| 129 | + $test->assertEquals('', $ap->getActivity($member)); | |
| 130 | + $test->assertEquals('', $ap->getActivity('00000000-0000-0000-0000-000000000000')); | |
| 131 | + $test->assertEquals('', $ap->getActivity('')); | |
| 132 | + $test->assertEquals('', $ap->getObject($member)); | |
| 133 | + }); | |
| 94 | 134 | |
| 95 | - $expect = 'Create'; | |
| 96 | - $test->assertEquals($json['type'], $expect); | |
| 135 | + $test->it('オブジェクト(Note)を受け取れるはず', function($test) use ($make, $posts, $feature, $domain): void { | |
| 136 | + $json = json_decode($make($posts)->getObject($feature), true); | |
| 137 | + $test->assertEquals('Note', $json['type']); | |
| 138 | + $test->assertEquals("{$domain}/ap/objects/{$feature}", $json['id']); | |
| 139 | + $test->assertTrue(isset($json['@context'])); | |
| 140 | + }); | |
| 97 | 141 | |
| 98 | - $expect = '【マークダウン】関数'; | |
| 99 | - $test->assertEquals($json['object']['name'], $expect); | |
| 142 | + $test->it('アウトボックスは公開投稿だけを新しい順に返すはず', function($test) use ($make, $posts, $member): void { | |
| 143 | + $json = json_decode($make($posts)->getOutbox(), true); | |
| 144 | + $test->assertEquals('OrderedCollection', $json['type']); | |
| 145 | + $test->assertEquals(count($json['orderedItems']), $json['totalItems']); | |
| 146 | + $ids = array_map(fn($i) => basename($i['object']['id']), $json['orderedItems']); | |
| 147 | + $test->assertFalse(in_array($member, $ids, true)); | |
| 148 | + $test->assertFalse(in_array('1c0ac334-a017-4813-9137-939b483c08eb', $ids, true)); // staffonly | |
| 149 | + $dates = array_map(fn($i) => $i['published'], $json['orderedItems']); | |
| 150 | + $sorted = $dates; | |
| 151 | + rsort($sorted); | |
| 152 | + $test->assertEquals($sorted, $dates); | |
| 153 | + }); | |
| 100 | 154 | |
| 101 | - $expect = 'https://www.w3.org/ns/activitystreams#Public'; | |
| 102 | - $test->assertEquals($json['object']['to'][0], $expect); | |
| 155 | + $test->it('フォローを受け入れてAcceptを送るはず', function($test) use ($make, $followers, $domain, $remoteActor): void { | |
| 156 | + file_put_contents($followers, ''); | |
| 157 | + $ap = $make(); | |
| 158 | + $ap->remote[$remoteActor] = ['inbox' => "{$remoteActor}/inbox"]; | |
| 159 | + $follow = ['type' => 'Follow', 'id' => "{$remoteActor}#follow", 'actor' => $remoteActor, 'object' => "{$domain}/ap/actor"]; | |
| 103 | 160 | |
| 104 | - $expect = 'zBbw7K7L1dEwr6htZX5JevRv3oLtHwXQU4o5jT8jn9RKF'; | |
| 105 | - $test->assertEquals($json['attachment'][0]['digestMultibase'], $expect); | |
| 161 | + [$code] = $ap->handleInbox($follow); | |
| 162 | + $test->assertEquals(200, $code); | |
| 163 | + $test->assertEquals("{$remoteActor}\n", file_get_contents($followers)); | |
| 164 | + $test->assertEquals("{$remoteActor}/inbox", $ap->sent[0][0]); | |
| 165 | + $test->assertEquals('Accept', $ap->sent[0][1]['type']); | |
| 166 | + $test->assertEquals($follow, $ap->sent[0][1]['object']); | |
| 106 | 167 | |
| 107 | - $expect = 'http://127.0.0.1:8000/static/article/o_53803618dc1691.28179609-orig.jpg'; | |
| 108 | - $test->assertEquals($json['attachment'][0]['url'], $expect); | |
| 109 | - }); | |
| 168 | + // 2回フォローしても1行だけ | |
| 169 | + $ap->handleInbox($follow); | |
| 170 | + $test->assertEquals("{$remoteActor}\n", file_get_contents($followers)); | |
| 171 | + | |
| 172 | + $list = json_decode($ap->getFollowers(), true); | |
| 173 | + $test->assertEquals('OrderedCollection', $list['type']); | |
| 174 | + $test->assertEquals([$remoteActor], $list['orderedItems']); | |
| 110 | 175 | }); |
| 111 | 176 | |
| 112 | - // TODO: 未完了 | |
| 177 | + $test->it('不正なフォローを拒否するはず', function($test) use ($make, $domain, $remoteActor): void { | |
| 178 | + $ap = $make(); | |
| 179 | + $test->assertEquals(400, $ap->handleInbox(['type' => 'Follow', 'actor' => $remoteActor, 'object' => 'https://1.1.1.1/someone'])[0]); | |
| 180 | + $test->assertEquals(400, $ap->handleInbox(['type' => 'Follow', 'actor' => 'http://127.0.0.1/x', 'object' => "{$domain}/ap/actor"])[0]); | |
| 181 | + // 受付ボックスが分からない | |
| 182 | + $test->assertEquals(500, $ap->handleInbox(['type' => 'Follow', 'actor' => $remoteActor, 'object' => "{$domain}/ap/actor"])[0]); | |
| 183 | + $test->assertEquals([], $ap->sent); | |
| 184 | + }); | |
| 113 | 185 | |
| 114 | - $test->printSummary(); | |
| 115 | -} | |
| 186 | + $test->it('フォローの取り消しでフォロワーから外すはず', function($test) use ($make, $followers, $domain, $remoteActor): void { | |
| 187 | + file_put_contents($followers, "{$remoteActor}\nhttps://1.1.1.1/users/bob\n"); | |
| 188 | + $ap = $make(); | |
| 189 | + | |
| 190 | + // IDだけのUndo(Likeの取り消しかも知れない)と他人のフォローの取り消しは無視する | |
| 191 | + $test->assertEquals(202, $ap->handleInbox(['type' => 'Undo', 'actor' => $remoteActor, 'object' => "{$remoteActor}#like"])[0]); | |
| 192 | + $other = ['type' => 'Follow', 'actor' => 'https://1.1.1.1/users/bob', 'object' => "{$domain}/ap/actor"]; | |
| 193 | + $test->assertEquals(202, $ap->handleInbox(['type' => 'Undo', 'actor' => $remoteActor, 'object' => $other])[0]); | |
| 194 | + $test->assertEquals("{$remoteActor}\nhttps://1.1.1.1/users/bob\n", file_get_contents($followers)); | |
| 195 | + | |
| 196 | + $mine = ['type' => 'Follow', 'actor' => $remoteActor, 'object' => "{$domain}/ap/actor"]; | |
| 197 | + $test->assertEquals(200, $ap->handleInbox(['type' => 'Undo', 'actor' => $remoteActor, 'object' => $mine])[0]); | |
| 198 | + $test->assertEquals("https://1.1.1.1/users/bob\n", file_get_contents($followers)); | |
| 199 | + }); | |
| 200 | + | |
| 201 | + $test->it('アカウントの削除でフォロワーから外すはず', function($test) use ($make, $followers): void { | |
| 202 | + file_put_contents($followers, "https://1.1.1.1/users/bob\n"); | |
| 203 | + $ap = $make(); | |
| 204 | + $test->assertEquals(202, $ap->handleInbox(['type' => 'Delete', 'actor' => 'https://1.1.1.1/users/bob', 'object' => 'https://1.1.1.1/users/bob/statuses/1'])[0]); | |
| 205 | + $test->assertEquals("https://1.1.1.1/users/bob\n", file_get_contents($followers)); | |
| 206 | + $test->assertEquals(202, $ap->handleInbox(['type' => 'Delete', 'actor' => 'https://1.1.1.1/users/bob', 'object' => 'https://1.1.1.1/users/bob'])[0]); | |
| 207 | + $test->assertEquals('', file_get_contents($followers)); | |
| 208 | + }); | |
| 209 | + | |
| 210 | + $test->it('未対応のアクティビティは202を返すはず', function($test) use ($make): void { | |
| 211 | + // 5xxを返すと相手のサーバーが何日も再送する | |
| 212 | + [$code] = $make()->handleInbox(['type' => 'Like', 'actor' => 'https://1.1.1.1/users/bob', 'object' => 'x']); | |
| 213 | + $test->assertEquals(202, $code); | |
| 214 | + }); | |
| 215 | + | |
| 216 | + $test->it('POSTとGETの署名を作れるはず', function($test) use ($make, $key): void { | |
| 217 | + if ($key === false) { | |
| 218 | + $test->assertTrue(true); | |
| 219 | + return; // OpenSSLの設定が無い環境 | |
| 220 | + } | |
| 221 | + $ap = $make(); | |
| 222 | + $parse = function(array $list): array { | |
| 223 | + $h = []; | |
| 224 | + foreach ($list as $line) { | |
| 225 | + [$k, $v] = explode(': ', $line, 2); | |
| 226 | + $h[$k] = $v; | |
| 227 | + } | |
| 228 | + return $h; | |
| 229 | + }; | |
| 230 | + | |
| 231 | + $h = $parse($ap->sign('https://a.example/inbox', '{}')); | |
| 232 | + $test->assertStringContains('headers="(request-target) host date digest"', $h['Signature']); | |
| 233 | + $test->assertEquals('SHA-256='.base64_encode(hash('sha256', '{}', true)), $h['Digest']); | |
| 234 | + | |
| 235 | + $h = $parse($ap->sign('https://a.example/users/x?y=1', '', 'get')); | |
| 236 | + $test->assertStringContains('headers="(request-target) host date"', $h['Signature']); | |
| 237 | + $test->assertFalse(isset($h['Digest'])); | |
| 238 | + preg_match('/signature="([^"]+)"/', $h['Signature'], $m); | |
| 239 | + $signed = "(request-target): get /users/x?y=1\nhost: a.example\ndate: {$h['Date']}"; | |
| 240 | + $test->assertEquals(1, openssl_verify($signed, base64_decode($m[1]), openssl_pkey_get_details($key)['key'], OPENSSL_ALGO_SHA256)); | |
| 241 | + }); | |
| 242 | + | |
| 243 | + $test->it('受付ボックスの署名を検証するはず', function($test) use ($make, $key, $domain, $remoteActor): void { | |
| 244 | + if ($key === false) { | |
| 245 | + $test->assertTrue(true); | |
| 246 | + return; | |
| 247 | + } | |
| 248 | + $ap = $make(); | |
| 249 | + $keyId = "{$domain}/ap/actor#main-key"; | |
| 250 | + $actor = "{$domain}/ap/actor"; | |
| 251 | + // 自分の鍵で署名したリクエストを「相手」として検証する | |
| 252 | + $ap->remote[$actor] = ['id' => $actor, 'publicKey' => ['id' => $keyId, 'owner' => $actor, | |
| 253 | + 'publicKeyPem' => openssl_pkey_get_details($key)['key']]]; | |
| 254 | + | |
| 255 | + $body = json_encode(['type' => 'Follow', 'actor' => $actor, 'object' => 'x']); | |
| 256 | + $headers = []; | |
| 257 | + foreach ($ap->sign('https://'.parse_url($domain, PHP_URL_HOST).'/ap/inbox', $body) as $line) { | |
| 258 | + [$k, $v] = explode(': ', $line, 2); | |
| 259 | + $headers[$k] = $v; | |
| 260 | + } | |
| 261 | + $server = $_SERVER; | |
| 262 | + $setup = function(array $override = []) use ($headers, $server): void { | |
| 263 | + $_SERVER = $server; | |
| 264 | + $_SERVER['REQUEST_METHOD'] = 'POST'; | |
| 265 | + $_SERVER['REQUEST_URI'] = '/ap/inbox'; | |
| 266 | + $_SERVER['HTTP_HOST'] = $headers['Host']; | |
| 267 | + $_SERVER['HTTP_DATE'] = $headers['Date']; | |
| 268 | + $_SERVER['HTTP_DIGEST'] = $headers['Digest']; | |
| 269 | + $_SERVER['HTTP_SIGNATURE'] = $headers['Signature']; | |
| 270 | + foreach ($override as $k => $v) $_SERVER[$k] = $v; | |
| 271 | + }; | |
| 272 | + | |
| 273 | + try { | |
| 274 | + $setup(); | |
| 275 | + $test->assertTrue($ap->verifyInboxSignature($body, json_decode($body, true)), '正しい署名'); | |
| 276 | + | |
| 277 | + $setup(); | |
| 278 | + $test->assertFalse($ap->verifyInboxSignature($body.' ', json_decode($body, true)), '本文の改竄'); | |
| 279 | + | |
| 280 | + $setup(['REQUEST_URI' => '/ap/other']); | |
| 281 | + $test->assertFalse($ap->verifyInboxSignature($body, json_decode($body, true)), 'パスの改竄'); | |
| 282 | + | |
| 283 | + $setup(['HTTP_DATE' => gmdate('D, d M Y H:i:s \G\M\T', time() - 3600)]); | |
| 284 | + $test->assertFalse($ap->verifyInboxSignature($body, json_decode($body, true)), '古い日付'); | |
| 285 | + | |
| 286 | + $setup(); | |
| 287 | + $test->assertFalse($ap->verifyInboxSignature($body, ['type' => 'Follow', 'actor' => $remoteActor]), '署名者とactorが違う'); | |
| 288 | + | |
| 289 | + $setup(['HTTP_SIGNATURE' => str_replace('(request-target) ', '', $headers['Signature'])]); | |
| 290 | + $test->assertFalse($ap->verifyInboxSignature($body, json_decode($body, true)), '(request-target)が無い'); | |
| 291 | + } finally { | |
| 292 | + $_SERVER = $server; | |
| 293 | + } | |
| 294 | + }); | |
| 295 | +}); | |
| 296 | + | |
| 297 | +@unlink($followers); | |
| 298 | + | |
| 299 | +$test->printSummary(); |
| @@ -0,0 +1,117 @@ | ||
| 1 | +<?php | |
| 2 | +/************************************************************* | |
| 3 | +# 076 License | |
| 4 | + | |
| 5 | +Copyright (c) テクニカル諏訪子 | |
| 6 | + | |
| 7 | +Permission is hereby granted to any person obtaining a copy of the software | |
| 8 | +Little Beast (the "Software") to use, modify, merge, copy, publish, distribute, | |
| 9 | +sublicense, and/or sell copies of the Software, subject to the following conditions: | |
| 10 | + | |
| 11 | + 1. **Origin Attribution**: | |
| 12 | + - You must not misrepresent the origin of the Software; you must not claim | |
| 13 | + you created the original Software. | |
| 14 | + - If the Software is used in a product, you must either: | |
| 15 | + a. Provide clear attribution in the product's documentation, user interface, | |
| 16 | + or other visible areas, **OR** | |
| 17 | + b. Pay the original developers a fee they specify in writing. | |
| 18 | + 2. **Usage Restriction**: | |
| 19 | + - The Software, or any derivative works, dependencies, or libraries | |
| 20 | + incorporating it, must not be used for censorship or to suppress freedom of | |
| 21 | + speech, expression, or creativity. Prohibited uses include, but are not | |
| 22 | + limited to: | |
| 23 | + - Censorship of so-called "hate speech", visuals, non-mainstream opinions, | |
| 24 | + ideas, or objective reality. | |
| 25 | + - Tools or systems designed to restrict access to information or | |
| 26 | + artistic works. | |
| 27 | + 3. **Notice Preservation**: | |
| 28 | + - This license and the above copyright notice must remain intact in all copies | |
| 29 | + of the source code. | |
| 30 | + | |
| 31 | +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, | |
| 32 | +INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A | |
| 33 | +PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT | |
| 34 | +HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF | |
| 35 | +CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE | |
| 36 | +OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. | |
| 37 | + */ | |
| 38 | +namespace Std\Test; | |
| 39 | + | |
| 40 | +require_once __DIR__.'/../../../autoload.php'; | |
| 41 | + | |
| 42 | +use Std\Lib\Tester; | |
| 43 | +use Std\Lib\Cache; | |
| 44 | + | |
| 45 | +$test = new Tester([ | |
| 46 | + 'colorOutput' => true, | |
| 47 | + 'verboseOutput' => true | |
| 48 | +]); | |
| 49 | + | |
| 50 | +$base = sys_get_temp_dir().'/lbcache_'.bin2hex(random_bytes(6)); | |
| 51 | + | |
| 52 | +$test->describe('キャッシュ', function($test) use ($base): void { | |
| 53 | + $test->it('保存して受け取れるはず(ディレクトリも作るはず)', function($test) use ($base): void { | |
| 54 | + $cache = new Cache('test', $base); | |
| 55 | + $res = $cache->set('domainlist', ['a' => 1, 'b' => ['日本語']]); | |
| 56 | + $test->assertTrue($res->isSuccess); | |
| 57 | + $test->assertTrue(is_file("{$base}/test/domainlist.json")); | |
| 58 | + | |
| 59 | + $data = $cache->get('domainlist'); | |
| 60 | + $test->assertEquals(1, $data['a']); | |
| 61 | + $test->assertEquals(['日本語'], $data['b']); | |
| 62 | + $test->assertTrue(is_int($data['lifespan'])); | |
| 63 | + }); | |
| 64 | + | |
| 65 | + $test->it('無いキャッシュは空の配列のはず', function($test) use ($base): void { | |
| 66 | + $test->assertEquals([], (new Cache('test', $base))->get('nothing')); | |
| 67 | + $test->assertEquals([], (new Cache('nodir', $base))->get('x')); | |
| 68 | + }); | |
| 69 | + | |
| 70 | + $test->it('壊れたJSONは空の配列のはず', function($test) use ($base): void { | |
| 71 | + file_put_contents("{$base}/test/broken.json", '{"a":'); | |
| 72 | + $test->assertEquals([], (new Cache('test', $base))->get('broken')); | |
| 73 | + }); | |
| 74 | + | |
| 75 | + $test->it('有効期限を確認出来るはず', function($test) use ($base): void { | |
| 76 | + $cache = new Cache('test', $base); | |
| 77 | + $cache->set('q', ['query' => ['page' => 1]]); | |
| 78 | + $data = $cache->get('q'); | |
| 79 | + $test->assertTrue($cache->resurrect($data)); | |
| 80 | + $test->assertTrue($cache->resurrect($data, ['page' => 1])); | |
| 81 | + $test->assertFalse($cache->resurrect($data, ['page' => 2])); | |
| 82 | + $test->assertFalse($cache->resurrect([])); | |
| 83 | + | |
| 84 | + $expired = $data; | |
| 85 | + $expired['lifespan'] = time() - 3600; | |
| 86 | + $test->assertFalse($cache->resurrect($expired)); | |
| 87 | + | |
| 88 | + // 有効期限を変えられる(protected) | |
| 89 | + $short = new class('test', $base) extends Cache { | |
| 90 | + protected int $dataDuration = 0; | |
| 91 | + }; | |
| 92 | + $test->assertFalse($short->resurrect($data)); | |
| 93 | + }); | |
| 94 | + | |
| 95 | + $test->it('削除出来るはず', function($test) use ($base): void { | |
| 96 | + $cache = new Cache('test', $base); | |
| 97 | + $test->assertTrue($cache->murder('domainlist')->isSuccess); | |
| 98 | + $test->assertEquals([], $cache->get('domainlist')); | |
| 99 | + $test->assertFalse($cache->murder('domainlist')->isSuccess); | |
| 100 | + }); | |
| 101 | + | |
| 102 | + $test->it('ディレクトリの外のファイル名を拒否するはず', function($test) use ($base): void { | |
| 103 | + $cache = new Cache('test', $base); | |
| 104 | + foreach (['../x', '..', 'a/b', 'a\\b', '', str_repeat('a', 201)] as $bad) { | |
| 105 | + $test->assertFalse($cache->set($bad, [])->isSuccess, "「{$bad}」が拒否されなかった"); | |
| 106 | + $test->assertEquals([], $cache->get($bad)); | |
| 107 | + $test->assertFalse($cache->murder($bad)->isSuccess); | |
| 108 | + } | |
| 109 | + $test->assertThrows(fn() => new Cache('../../etc', $base), \InvalidArgumentException::class); | |
| 110 | + }); | |
| 111 | +}); | |
| 112 | + | |
| 113 | +foreach (glob("{$base}/*/*") ?: [] as $f) @unlink($f); | |
| 114 | +foreach (glob("{$base}/*") ?: [] as $d) @rmdir($d); | |
| 115 | +@rmdir($base); | |
| 116 | + | |
| 117 | +$test->printSummary(); |
| @@ -41,90 +41,216 @@ | ||
| 41 | 41 | |
| 42 | 42 | use Std\Lib\Tester; |
| 43 | 43 | use Std\Lib\Curl; |
| 44 | +use Std\Test\Helper\MockServer; | |
| 44 | 45 | |
| 45 | -if (CURL_ENABLED) { | |
| 46 | - $test = new Tester([ | |
| 47 | - 'colorOutput' => true, | |
| 48 | - 'verboseOutput' => true | |
| 49 | - ]); | |
| 46 | +$test = new Tester([ | |
| 47 | + 'colorOutput' => true, | |
| 48 | + 'verboseOutput' => true | |
| 49 | +]); | |
| 50 | 50 | |
| 51 | - $test->describe('Curlの基本的な機能性', function($test): void { | |
| 52 | - $test->it('URLで使って新しいインスタンスを作成するはず', function($test): void { | |
| 53 | - $curl = new Curl('https://076.moe'); | |
| 54 | - $test->assertNotNull($curl); | |
| 55 | - }); | |
| 51 | +// 偽物のサーバーに繋ぐCurl(127.0.0.1なので非公開アドレスの拒否を外す) | |
| 52 | +$local = fn(string $url) => (new Curl($url))->setBlockPrivateAddresses(false)->setTimeout(5); | |
| 56 | 53 | |
| 57 | - $test->it('メソッドでURLを設定出来るはす', function($test): void { | |
| 58 | - $curl = new Curl(); | |
| 59 | - $curl->setUrl('https://076.moe'); | |
| 60 | - $test->assertNotNull($curl); | |
| 61 | - }); | |
| 54 | +$test->describe('Curlの基本的な機能性', function($test) use ($local): void { | |
| 55 | + if (!CURL_ENABLED) { | |
| 56 | + $test->skipAll('CURL_ENABLEDが無効'); | |
| 57 | + } | |
| 62 | 58 | |
| 63 | - $test->it('ヘッダー文字を作成出来るはず', function($test): void { | |
| 64 | - $curl = new Curl('https://076.moe'); | |
| 65 | - $curl->setHeaders([ | |
| 66 | - 'Accept' => 'application/json', | |
| 67 | - 'User-Agent' => 'LoliTest/1.0' | |
| 68 | - ]); | |
| 59 | + $test->it('メソッドチェーニングを対応するはず', function($test): void { | |
| 60 | + $curl = new Curl(); | |
| 61 | + $result = $curl->setUrl('https://076.moe') | |
| 62 | + ->setMethod('get') | |
| 63 | + ->setTimeout(30) | |
| 64 | + ->setHeaders(['Accept' => 'text/html']) | |
| 65 | + ->addHeader('X-Test', '1') | |
| 66 | + ->setUserAgent('LoliTest/1.0') | |
| 67 | + ->setFollowRedirects(false) | |
| 68 | + ->setBlockPrivateAddresses(true); | |
| 69 | + $test->assertSame($curl, $result); | |
| 70 | + }); | |
| 69 | 71 | |
| 70 | - $reflectionClass = new \ReflectionClass($curl); | |
| 71 | - $method = $reflectionClass->getMethod('buildHeaderString'); | |
| 72 | - $method->setAccessible(true); | |
| 72 | + $test->it('URLが無ければエラーになるはず', function($test): void { | |
| 73 | + $res = (new Curl())->execute(); | |
| 74 | + $test->assertFalse($res->isSuccess); | |
| 75 | + $test->assertEquals('URLがありません', $res->message); | |
| 76 | + }); | |
| 73 | 77 | |
| 74 | - $headerString = $method->invoke($curl); | |
| 75 | - $test->assertStringContains('Accept: application/json', $headerString); | |
| 76 | - $test->assertStringContains('User-Agent: LoliTest/1.0', $headerString); | |
| 77 | - }); | |
| 78 | + $test->it('不正なURLとスキームを拒否するはず', function($test): void { | |
| 79 | + $test->assertStringContains('無効なURL', (new Curl("http://exa mple.com/"))->execute()->message); | |
| 80 | + $test->assertStringContains('未対応のスキーム', (new Curl('ftp://076.moe/'))->execute()->message); | |
| 81 | + $test->assertStringContains('無効なURL', (new Curl('/relative/only'))->execute()->message); | |
| 82 | + }); | |
| 78 | 83 | |
| 79 | - $test->skip('移転を対応するはず', '作成中・・・'); | |
| 84 | + $test->it('非公開アドレスへの接続を既定で拒否するはず', function($test): void { | |
| 85 | + foreach (['http://127.0.0.1/', 'http://10.0.0.1/', 'http://192.168.1.1/', 'http://[::1]/', 'http://169.254.169.254/'] as $url) { | |
| 86 | + $res = (new Curl($url))->execute(); | |
| 87 | + $test->assertFalse($res->isSuccess, "{$url}が拒否されなかった"); | |
| 88 | + $test->assertStringContains('非公開アドレス', $res->message); | |
| 89 | + } | |
| 90 | + }); | |
| 80 | 91 | |
| 81 | - $test->it('メソッドチェーニングを対応するはず', function($test): void { | |
| 82 | - $curl = new Curl(); | |
| 83 | - $result = $curl->setUrl('https://076.moe') | |
| 84 | - ->setMethod('GET') | |
| 85 | - ->setTimeout(30); | |
| 92 | + $test->it('改行を含むヘッダーを拒否するはず(ヘッダー注入)', function($test) use ($local): void { | |
| 93 | + // ヘッダーは接続の前に確認するので、サーバーは要らない | |
| 94 | + $res = $local('http://127.0.0.1:9/')->addHeader('X-Evil', "a\r\nHost: evil")->execute(); | |
| 95 | + $test->assertFalse($res->isSuccess); | |
| 96 | + $test->assertStringContains('不正なヘッダー', $res->message); | |
| 86 | 97 | |
| 87 | - $test->assertSame($curl, $result); | |
| 88 | - }); | |
| 98 | + $res = $local('http://127.0.0.1:9/')->setHeaders(["Bad Name: x"])->execute(); | |
| 99 | + $test->assertStringContains('不正なヘッダー', $res->message); | |
| 100 | + | |
| 101 | + $res = $local('http://127.0.0.1:9/')->setReferer("https://a/\r\nX: y")->execute(); | |
| 102 | + $test->assertStringContains('不正なヘッダー', $res->message); | |
| 89 | 103 | }); |
| 90 | 104 | |
| 91 | - $test->describe('Curl HTTP リクエスト', function($test): void { | |
| 92 | - $networkAvailable = true; | |
| 105 | + $test->it('名前解決に失敗したら分かるはず', function($test): void { | |
| 106 | + $res = (new Curl('https://does-not-exist.invalid/'))->execute(); | |
| 107 | + $test->assertFalse($res->isSuccess); | |
| 108 | + $test->assertStringContains('名前解決に失敗', $res->message); | |
| 109 | + }); | |
| 110 | +}); | |
| 93 | 111 | |
| 94 | - if (!$networkAvailable) { | |
| 95 | - $test->skip('076.moeでGETリクエストの確認', 'ネットワークが無効です。'); | |
| 96 | - $test->skip('postman-echo.comでPOSTリクエストの確認', 'ネットワークが無効です。'); | |
| 97 | - return; | |
| 98 | - } | |
| 112 | +$test->describe('Curl HTTPリクエスト(偽物のサーバー)', function($test) use ($local): void { | |
| 113 | + $srv = CURL_ENABLED ? MockServer::start('http-server.php') : null; | |
| 114 | + if ($srv === null) { | |
| 115 | + $test->skipAll('偽物のサーバーを起動出来ない(proc_openが無効?)'); | |
| 116 | + } | |
| 99 | 117 | |
| 100 | - $test->it('076.moeでGETリクエストの確認', function($test): void { | |
| 101 | - $curl = new Curl('https://076.moe'); | |
| 102 | - $result = $curl->execute(); | |
| 118 | + $test->it('GETリクエストを送れるはず', function($test) use ($srv, $local): void { | |
| 119 | + $curl = $local($srv->url('/echo?a=1&b=%E3%81%82')); | |
| 120 | + $res = $curl->execute(); | |
| 121 | + $test->assertTrue($res->isSuccess); | |
| 122 | + $test->assertEquals(200, $curl->getResponseCode()); | |
| 103 | 123 | |
| 104 | - $test->assertTrue($result->isSuccess); | |
| 105 | - $test->assertEquals(200, $curl->getResponseCode()); | |
| 106 | - $test->assertNotNull($curl->getResponseBody()); | |
| 107 | - $test->assertStringContains('<html', $curl->getResponseBody()); | |
| 108 | - }); | |
| 124 | + $req = json_decode($curl->getResponseBody(), true); | |
| 125 | + $test->assertEquals('GET', $req['method']); | |
| 126 | + $test->assertEquals('/echo?a=1&b=%E3%81%82', $req['target']); | |
| 127 | + $test->assertEquals("127.0.0.1", $req['headers']['host']); | |
| 128 | + $test->assertEquals('LittleBeast/1.0', $req['headers']['user-agent']); | |
| 129 | + $test->assertEquals('application/json', $curl->getResponseHeaders()['Content-Type']); | |
| 130 | + }); | |
| 109 | 131 | |
| 110 | - $test->it('postman-echo.comでPOSTリクエストの確認', function($test): void { | |
| 111 | - $curl = new Curl(); | |
| 112 | - $curl->setUrl('https://postman-echo.com/post') | |
| 113 | - ->setMethod('POST') | |
| 114 | - ->setPostFields([ | |
| 115 | - 'name' => '山田太郎', | |
| 116 | - 'email' => 't.yamada@example.com' | |
| 117 | - ]); | |
| 132 | + $test->it('POSTのフォームと生データを送れるはず', function($test) use ($srv, $local): void { | |
| 133 | + $curl = $local($srv->url('/echo'))->setMethod('POST')->setPostFields(['name' => '山田太郎', 'email' => 't.yamada@example.com']); | |
| 134 | + $curl->execute(); | |
| 135 | + $req = json_decode($curl->getResponseBody(), true); | |
| 136 | + $test->assertEquals('POST', $req['method']); | |
| 137 | + $test->assertEquals('application/x-www-form-urlencoded', $req['headers']['content-type']); | |
| 138 | + parse_str($req['body'], $fields); | |
| 139 | + $test->assertEquals(['name' => '山田太郎', 'email' => 't.yamada@example.com'], $fields); | |
| 118 | 140 | |
| 119 | - $result = $curl->execute(); | |
| 120 | - $test->assertTrue($result->isSuccess); | |
| 121 | - $test->assertEquals(200, $curl->getResponseCode()); | |
| 141 | + $curl = $local($srv->url('/echo'))->setMethod('POST')->setPostRaw('{"x":1}')->addHeader('Content-Type', 'application/json'); | |
| 142 | + $curl->execute(); | |
| 143 | + $req = json_decode($curl->getResponseBody(), true); | |
| 144 | + $test->assertEquals('{"x":1}', $req['body']); | |
| 145 | + $test->assertEquals('7', $req['headers']['content-length']); | |
| 146 | + $test->assertEquals('application/json', $req['headers']['content-type']); | |
| 147 | + }); | |
| 122 | 148 | |
| 123 | - $responseBody = $curl->getResponseBody(); | |
| 124 | - $test->assertStringContains('山田太郎', $responseBody); | |
| 125 | - $test->assertStringContains('t.yamada@example.com', $responseBody); | |
| 126 | - }); | |
| 149 | + $test->it('ヘッダーは両方の形式を受け付けるはず', function($test) use ($srv, $local): void { | |
| 150 | + $curl = $local($srv->url('/echo'))->setHeaders(['X-A' => '1', 'X-B: 2', 'Accept' => 'text/plain', 'User-Agent: Foo/2']); | |
| 151 | + $curl->execute(); | |
| 152 | + $h = json_decode($curl->getResponseBody(), true)['headers']; | |
| 153 | + $test->assertEquals('1', $h['x-a']); | |
| 154 | + $test->assertEquals('2', $h['x-b']); | |
| 155 | + $test->assertEquals('text/plain', $h['accept']); | |
| 156 | + $test->assertEquals('Foo/2', $h['user-agent']); | |
| 127 | 157 | }); |
| 128 | 158 | |
| 129 | - $test->printSummary(); | |
| 130 | -} | |
| 159 | + $test->it('Basic・Bearer認証とクッキーを送れるはず', function($test) use ($srv, $local): void { | |
| 160 | + $curl = $local($srv->url('/echo'))->setBasicAuth('user', 'pa:ss'); | |
| 161 | + $curl->execute(); | |
| 162 | + $h = json_decode($curl->getResponseBody(), true)['headers']; | |
| 163 | + $test->assertEquals('Basic '.base64_encode('user:pa:ss'), $h['authorization']); | |
| 164 | + | |
| 165 | + $curl = $local($srv->url('/echo'))->setBearerAuth('abc')->setCookies(['a' => '1 2', 'b' => 'x']); | |
| 166 | + $curl->execute(); | |
| 167 | + $h = json_decode($curl->getResponseBody(), true)['headers']; | |
| 168 | + $test->assertEquals('Bearer abc', $h['authorization']); | |
| 169 | + $test->assertEquals('a=1+2; b=x', $h['cookie']); | |
| 170 | + | |
| 171 | + $curl = $local($srv->url('/echo'))->setBearerAuth('abc')->unsetAuth(); | |
| 172 | + $curl->execute(); | |
| 173 | + $test->assertFalse(isset(json_decode($curl->getResponseBody(), true)['headers']['authorization'])); | |
| 174 | + }); | |
| 175 | + | |
| 176 | + $test->it('チャンク転送をデコードするはず', function($test) use ($srv, $local): void { | |
| 177 | + $curl = $local($srv->url('/chunked')); | |
| 178 | + $curl->execute(); | |
| 179 | + $test->assertEquals('Hello, world!', $curl->getResponseBody()); | |
| 180 | + }); | |
| 181 | + | |
| 182 | + $test->it('リダイレクトを追うはず', function($test) use ($srv, $local): void { | |
| 183 | + $curl = $local($srv->url('/redirect-301')); | |
| 184 | + $curl->execute(); | |
| 185 | + $test->assertEquals(200, $curl->getResponseCode()); | |
| 186 | + $test->assertEquals('/echo?from=301', json_decode($curl->getResponseBody(), true)['target']); | |
| 187 | + $test->assertEquals(1, $curl->getInfo()['redirect_count']); | |
| 188 | + | |
| 189 | + $curl = $local($srv->url('/redirect-301'))->setFollowRedirects(false); | |
| 190 | + $curl->execute(); | |
| 191 | + $test->assertEquals(301, $curl->getResponseCode()); | |
| 192 | + }); | |
| 193 | + | |
| 194 | + $test->it('303ではGETに変わり、本文を送らないはず', function($test) use ($srv, $local): void { | |
| 195 | + $curl = $local($srv->url('/redirect-303'))->setMethod('POST')->setPostRaw('secret=1'); | |
| 196 | + $curl->execute(); | |
| 197 | + $req = json_decode($curl->getResponseBody(), true); | |
| 198 | + $test->assertEquals('GET', $req['method']); | |
| 199 | + $test->assertEquals('', $req['body']); | |
| 200 | + $test->assertFalse(isset($req['headers']['content-length'])); | |
| 201 | + }); | |
| 202 | + | |
| 203 | + $test->it('別のオリジンへのリダイレクトでは認証情報とクッキーを送らないはず', function($test) use ($srv, $local): void { | |
| 204 | + $to = urlencode($srv->url('/echo', 'localhost')); | |
| 205 | + $curl = $local($srv->url("/redirect?to={$to}"))->setBearerAuth('secret')->addCookie('session', 'x') | |
| 206 | + ->addHeader('Authorization', 'Bearer secret2'); | |
| 207 | + $curl->execute(); | |
| 208 | + $h = json_decode($curl->getResponseBody(), true)['headers']; | |
| 209 | + $test->assertEquals('localhost', $h['host']); | |
| 210 | + $test->assertFalse(isset($h['authorization'])); | |
| 211 | + $test->assertFalse(isset($h['cookie'])); | |
| 212 | + }); | |
| 213 | + | |
| 214 | + $test->it('リダイレクトの回数に上限があるはず', function($test) use ($srv, $local): void { | |
| 215 | + $curl = $local($srv->url('/loop'))->setMaxRedirects(3); | |
| 216 | + $res = $curl->execute(); | |
| 217 | + $test->assertEquals(302, $curl->getResponseCode()); | |
| 218 | + $test->assertEquals(3, $curl->getInfo()['redirect_count']); | |
| 219 | + }); | |
| 220 | + | |
| 221 | + $test->it('大き過ぎるレスポンスを拒否するはず', function($test) use ($srv, $local): void { | |
| 222 | + $res = $local($srv->url('/big'))->setMaxResponseSize(1000)->execute(); | |
| 223 | + $test->assertFalse($res->isSuccess); | |
| 224 | + $test->assertStringContains('大き過ぎます', $res->message); | |
| 225 | + }); | |
| 226 | + | |
| 227 | + $test->it('タイムアウトするはず', function($test) use ($srv, $local): void { | |
| 228 | + $start = microtime(true); | |
| 229 | + $res = $local($srv->url('/slow'))->setTimeout(1)->execute(); | |
| 230 | + $test->assertFalse($res->isSuccess); | |
| 231 | + $test->assertStringContains('タイムアウト', $res->message); | |
| 232 | + $test->assertTrue(microtime(true) - $start < 2.5, 'タイムアウトに時間が掛かり過ぎ'); | |
| 233 | + }); | |
| 234 | + | |
| 235 | + $test->it('エラーのステータスもレスポンスとして返すはず', function($test) use ($srv, $local): void { | |
| 236 | + $curl = $local($srv->url('/missing')); | |
| 237 | + $res = $curl->execute(); | |
| 238 | + $test->assertTrue($res->isSuccess); | |
| 239 | + $test->assertEquals(404, $curl->getResponseCode()); | |
| 240 | + $test->assertEquals(['desc' => 'Not found'], json_decode($curl->getResponseBody(), true)); | |
| 241 | + }); | |
| 242 | + | |
| 243 | + $test->it('接続出来ない場合はエラーになるはず', function($test) use ($local): void { | |
| 244 | + // 使われていないポートを探す | |
| 245 | + $probe = stream_socket_server('tcp://127.0.0.1:0'); | |
| 246 | + $port = (int)substr(strrchr(stream_socket_get_name($probe, false), ':'), 1); | |
| 247 | + fclose($probe); | |
| 248 | + $res = $local("http://127.0.0.1:{$port}/")->setTimeout(2)->execute(); | |
| 249 | + $test->assertFalse($res->isSuccess); | |
| 250 | + $test->assertStringContains('接続出来ません', $res->message); | |
| 251 | + }); | |
| 252 | + | |
| 253 | + $srv?->stop(); | |
| 254 | +}); | |
| 255 | + | |
| 256 | +$test->printSummary(); |
| @@ -0,0 +1,133 @@ | ||
| 1 | +<?php | |
| 2 | +/************************************************************* | |
| 3 | +# 076 License | |
| 4 | + | |
| 5 | +Copyright (c) テクニカル諏訪子 | |
| 6 | + | |
| 7 | +Permission is hereby granted to any person obtaining a copy of the software | |
| 8 | +Little Beast (the "Software") to use, modify, merge, copy, publish, distribute, | |
| 9 | +sublicense, and/or sell copies of the Software, subject to the following conditions: | |
| 10 | + | |
| 11 | + 1. **Origin Attribution**: | |
| 12 | + - You must not misrepresent the origin of the Software; you must not claim | |
| 13 | + you created the original Software. | |
| 14 | + - If the Software is used in a product, you must either: | |
| 15 | + a. Provide clear attribution in the product's documentation, user interface, | |
| 16 | + or other visible areas, **OR** | |
| 17 | + b. Pay the original developers a fee they specify in writing. | |
| 18 | + 2. **Usage Restriction**: | |
| 19 | + - The Software, or any derivative works, dependencies, or libraries | |
| 20 | + incorporating it, must not be used for censorship or to suppress freedom of | |
| 21 | + speech, expression, or creativity. Prohibited uses include, but are not | |
| 22 | + limited to: | |
| 23 | + - Censorship of so-called "hate speech", visuals, non-mainstream opinions, | |
| 24 | + ideas, or objective reality. | |
| 25 | + - Tools or systems designed to restrict access to information or | |
| 26 | + artistic works. | |
| 27 | + 3. **Notice Preservation**: | |
| 28 | + - This license and the above copyright notice must remain intact in all copies | |
| 29 | + of the source code. | |
| 30 | + | |
| 31 | +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, | |
| 32 | +INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A | |
| 33 | +PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT | |
| 34 | +HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF | |
| 35 | +CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE | |
| 36 | +OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. | |
| 37 | + */ | |
| 38 | +namespace Std\Test; | |
| 39 | + | |
| 40 | +require_once __DIR__.'/../../../autoload.php'; | |
| 41 | + | |
| 42 | +use Std\Lib\Tester; | |
| 43 | +use Std\Lib\DiffViewer; | |
| 44 | + | |
| 45 | +$test = new Tester([ | |
| 46 | + 'colorOutput' => true, | |
| 47 | + 'verboseOutput' => true | |
| 48 | +]); | |
| 49 | + | |
| 50 | +$tmpFiles = []; | |
| 51 | +$view = function(string $diff) use (&$tmpFiles): string { | |
| 52 | + $f = tempnam(sys_get_temp_dir(), 'lbdiff_'); | |
| 53 | + file_put_contents($f, $diff); | |
| 54 | + $tmpFiles[] = $f; | |
| 55 | + return (new DiffViewer($f))->displaySideBySide(); | |
| 56 | +}; | |
| 57 | +// <td>の行を [クラス, 内容] の配列にする | |
| 58 | +$cells = function(string $html): array { | |
| 59 | + preg_match_all('#<td class="([\w-]+)">(.*?)</td>#s', $html, $m, PREG_SET_ORDER); | |
| 60 | + return array_map(fn($x) => [$x[1], $x[2]], $m); | |
| 61 | +}; | |
| 62 | + | |
| 63 | +$test->describe('差分ビューア', function($test) use ($view, $cells): void { | |
| 64 | + $test->it('無いファイルは例外のはず', function($test): void { | |
| 65 | + $test->assertThrows(fn() => new DiffViewer('/no/such/file.diff'), \Exception::class); | |
| 66 | + }); | |
| 67 | + | |
| 68 | + $test->it('git形式の差分を左右に並べるはず', function($test) use ($view, $cells): void { | |
| 69 | + $html = $view("diff --git a/main.c b/main.c\nindex 1..2 100644\n--- a/main.c\n+++ b/main.c\n" | |
| 70 | + ."@@ -1,3 +1,3 @@\n #include <stdio.h>\n-int a;\n+int b;\n return 0;\n"); | |
| 71 | + $test->assertStringContains('<h2>ファイル: a/main.c | b/main.c</h2>', $html); | |
| 72 | + $test->assertStringContains('<td colspan="4">@@ -1,3 +1,3 @@</td>', $html); | |
| 73 | + | |
| 74 | + $c = $cells($html); | |
| 75 | + $test->assertEquals(['line-number', '1'], $c[0]); | |
| 76 | + $test->assertEquals(['context', '#include <stdio.h>'], $c[1]); | |
| 77 | + $test->assertEquals(['removed', 'int a;'], $c[5]); | |
| 78 | + $test->assertEquals(['added', 'int b;'], $c[7]); | |
| 79 | + $test->assertEquals(['line-number', '2'], $c[6]); | |
| 80 | + }); | |
| 81 | + | |
| 82 | + $test->it('削除と追加の数が違う場合は空のセルで埋めるはず', function($test) use ($view): void { | |
| 83 | + $html = $view("--- a.txt\n+++ b.txt\n@@ -1,2 +1,1 @@\n-x\n-y\n+z\n"); | |
| 84 | + $test->assertStringContains('<td class="empty"> </td>', $html); | |
| 85 | + $test->assertEquals(1, substr_count($html, 'class="empty"')); | |
| 86 | + }); | |
| 87 | + | |
| 88 | + $test->it('「---」で始まる削除行をファイルヘッダーと間違えないはず', function($test) use ($view): void { | |
| 89 | + $html = $view("--- a/x.md\n+++ b/x.md\n@@ -1,2 +1,1 @@\n---- 区切り\n keep\n"); | |
| 90 | + $test->assertEquals(1, substr_count($html, '<h2>')); | |
| 91 | + $test->assertStringContains('<td class="removed">--- 区切り</td>', $html); | |
| 92 | + }); | |
| 93 | + | |
| 94 | + $test->it('行数が省略されたハンクと新しいファイルを扱えるはず', function($test) use ($view, $cells): void { | |
| 95 | + $html = $view("diff --git a/new.txt b/new.txt\nnew file mode 100644\n--- /dev/null\n+++ b/new.txt\n@@ -0,0 +1 @@\n+hello\n\\ No newline at end of file\n"); | |
| 96 | + $test->assertStringContains('<h2>ファイル: /dev/null | b/new.txt</h2>', $html); | |
| 97 | + $c = $cells($html); | |
| 98 | + $test->assertEquals(['added', 'hello'], $c[3]); | |
| 99 | + $test->assertEquals(['line-number', '1'], $c[2]); | |
| 100 | + }); | |
| 101 | + | |
| 102 | + $test->it('複数のファイルと内容の無い変更を扱えるはず', function($test) use ($view): void { | |
| 103 | + $html = $view("diff --git a/a.png b/a.png\nBinary files a/a.png and b/a.png differ\n" | |
| 104 | + ."diff --git a/old.sh b/new.sh\nsimilarity index 100%\nrename from old.sh\nrename to new.sh\n" | |
| 105 | + ."diff --git a/c.txt b/c.txt\n--- a/c.txt\n+++ b/c.txt\n@@ -1 +1 @@\n-1\n+2\n"); | |
| 106 | + $test->assertEquals(3, substr_count($html, '<h2>')); | |
| 107 | + $test->assertStringContains('<p>バイナリファイル</p>', $html); | |
| 108 | + $test->assertStringContains('<h2>ファイル: a/old.sh | b/new.sh</h2>', $html); | |
| 109 | + $test->assertStringContains('類似度:100% / 名前の変更元:old.sh / 名前の変更先:new.sh', $html); | |
| 110 | + }); | |
| 111 | + | |
| 112 | + $test->it('HTMLをエスケープするはず', function($test) use ($view): void { | |
| 113 | + $html = $view("--- a/<b>.html\n+++ b/<b>.html\n@@ -1 +1 @@\n-<script>alert(1)</script>\n+&\n"); | |
| 114 | + $test->assertFalse(str_contains($html, '<script>')); | |
| 115 | + $test->assertFalse(str_contains($html, '<b>')); | |
| 116 | + $test->assertStringContains('<script>', $html); | |
| 117 | + $test->assertStringContains('&amp;', $html); | |
| 118 | + }); | |
| 119 | + | |
| 120 | + $test->it('CRLFの差分も扱えるはず', function($test) use ($view): void { | |
| 121 | + $html = $view("--- a.txt\r\n+++ b.txt\r\n@@ -1 +1 @@\r\n-x\r\n+y\r\n"); | |
| 122 | + $test->assertStringContains('<td class="removed">x</td>', $html); | |
| 123 | + $test->assertStringContains('<td class="added">y</td>', $html); | |
| 124 | + }); | |
| 125 | + | |
| 126 | + $test->it('空の差分は何も出力しないはず', function($test) use ($view): void { | |
| 127 | + $test->assertEquals('', $view('')); | |
| 128 | + }); | |
| 129 | +}); | |
| 130 | + | |
| 131 | +foreach ($tmpFiles as $f) @unlink($f); | |
| 132 | + | |
| 133 | +$test->printSummary(); |
| @@ -0,0 +1,112 @@ | ||
| 1 | +<?php | |
| 2 | +/************************************************************* | |
| 3 | +# 076 License | |
| 4 | + | |
| 5 | +Copyright (c) テクニカル諏訪子 | |
| 6 | + | |
| 7 | +Permission is hereby granted to any person obtaining a copy of the software | |
| 8 | +Little Beast (the "Software") to use, modify, merge, copy, publish, distribute, | |
| 9 | +sublicense, and/or sell copies of the Software, subject to the following conditions: | |
| 10 | + | |
| 11 | + 1. **Origin Attribution**: | |
| 12 | + - You must not misrepresent the origin of the Software; you must not claim | |
| 13 | + you created the original Software. | |
| 14 | + - If the Software is used in a product, you must either: | |
| 15 | + a. Provide clear attribution in the product's documentation, user interface, | |
| 16 | + or other visible areas, **OR** | |
| 17 | + b. Pay the original developers a fee they specify in writing. | |
| 18 | + 2. **Usage Restriction**: | |
| 19 | + - The Software, or any derivative works, dependencies, or libraries | |
| 20 | + incorporating it, must not be used for censorship or to suppress freedom of | |
| 21 | + speech, expression, or creativity. Prohibited uses include, but are not | |
| 22 | + limited to: | |
| 23 | + - Censorship of so-called "hate speech", visuals, non-mainstream opinions, | |
| 24 | + ideas, or objective reality. | |
| 25 | + - Tools or systems designed to restrict access to information or | |
| 26 | + artistic works. | |
| 27 | + 3. **Notice Preservation**: | |
| 28 | + - This license and the above copyright notice must remain intact in all copies | |
| 29 | + of the source code. | |
| 30 | + | |
| 31 | +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, | |
| 32 | +INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A | |
| 33 | +PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT | |
| 34 | +HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF | |
| 35 | +CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE | |
| 36 | +OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. | |
| 37 | + */ | |
| 38 | +namespace Std\Test; | |
| 39 | + | |
| 40 | +require_once __DIR__.'/../../../autoload.php'; | |
| 41 | + | |
| 42 | +use Std\Lib\Tester; | |
| 43 | +use Std\Lib\Image; | |
| 44 | + | |
| 45 | +$test = new Tester([ | |
| 46 | + 'colorOutput' => true, | |
| 47 | + 'verboseOutput' => true | |
| 48 | +]); | |
| 49 | + | |
| 50 | +// テスト用の小さい画像を作る(Imageのパスはルートからの相対パス) | |
| 51 | +$rel = '/data/lbimg_'.bin2hex(random_bytes(6)); | |
| 52 | +$dir = ROOT.$rel; | |
| 53 | +mkdir($dir, 0700, true); | |
| 54 | + | |
| 55 | +$chunk = fn(string $t, string $d) => pack('N', strlen($d)).$t.$d.pack('N', crc32($t.$d)); | |
| 56 | +$raw = ''; | |
| 57 | +for ($y = 0; $y < 2; $y++) $raw .= "\0".str_repeat("\xff\x00\x00", 3); | |
| 58 | +file_put_contents("{$dir}/red.png", "\x89PNG\r\n\x1a\n".$chunk('IHDR', pack('NNCCCCC', 3, 2, 8, 2, 0, 0, 0)) | |
| 59 | + .$chunk('IDAT', gzcompress($raw)).$chunk('IEND', '')); | |
| 60 | +file_put_contents("{$dir}/a.gif", "GIF89a".pack('vv', 4, 5)."\x80\x00\x00\x00\x00\x00\xff\xff\xff" | |
| 61 | + ."\x2c".pack('vvvv', 0, 0, 4, 5)."\x00\x02\x02\x44\x01\x00\x3b"); | |
| 62 | +file_put_contents("{$dir}/a.jpg", "\xff\xd8\xff\xe0".pack('n', 16)."JFIF\0\x01\x01\x00\x00\x01\x00\x01\x00\x00" | |
| 63 | + ."\xff\xc0".pack('n', 17)."\x08".pack('nn', 6, 7)."\x03\x01\x22\x00\x02\x11\x01\x03\x11\x01\xff\xd9"); | |
| 64 | +file_put_contents("{$dir}/a.tga", pack('CCCvvCvvvvCC', 0, 0, 2, 0, 0, 0, 0, 0, 2, 3, 24, 0).str_repeat("\x00\x00\xff", 6)); | |
| 65 | +file_put_contents("{$dir}/fake.png", 'これは画像ではない'); | |
| 66 | +file_put_contents("{$dir}/big.png", "\x89PNG\r\n\x1a\n".str_repeat("\0", 3000)); | |
| 67 | + | |
| 68 | +$test->describe('画像', function($test) use ($rel): void { | |
| 69 | + $test->it('PNGの情報を読めるはず', function($test) use ($rel): void { | |
| 70 | + $img = new Image("{$rel}/red.png"); | |
| 71 | + $test->assertEquals('image/png', $img->type); | |
| 72 | + $test->assertEquals('red', $img->filename); | |
| 73 | + $test->assertEquals('png', $img->extension); | |
| 74 | + $test->assertEquals(filesize(ROOT."{$rel}/red.png"), $img->bytes); | |
| 75 | + $test->assertEquals(3, $img->fullInfo->width); | |
| 76 | + $test->assertEquals(2, $img->fullInfo->height); | |
| 77 | + $test->assertEquals('image/png', $img->fullInfo->type); | |
| 78 | + }); | |
| 79 | + | |
| 80 | + $test->it('GIF・JPEG・TGAの大きさを読めるはず', function($test) use ($rel): void { | |
| 81 | + foreach (['a.gif' => ['image/gif', 4, 5], 'a.jpg' => ['image/jpeg', 7, 6], 'a.tga' => ['image/x-tga', 2, 3]] as $f => [$type, $w, $h]) { | |
| 82 | + $img = new Image("{$rel}/{$f}"); | |
| 83 | + $test->assertEquals($type, $img->type, "{$f}の種類"); | |
| 84 | + $test->assertEquals($w, $img->fullInfo->width, "{$f}の横"); | |
| 85 | + $test->assertEquals($h, $img->fullInfo->height, "{$f}の丈"); | |
| 86 | + } | |
| 87 | + }); | |
| 88 | + | |
| 89 | + $test->it('大きさを読み易い形式にするはず', function($test) use ($rel): void { | |
| 90 | + $test->assertEquals('41 B', (new Image("{$rel}/a.jpg"))->size); | |
| 91 | + $test->assertEquals('2.94 KiB', (new Image("{$rel}/big.png"))->size); | |
| 92 | + }); | |
| 93 | + | |
| 94 | + $test->it('無いファイルは何もしないはず', function($test) use ($rel): void { | |
| 95 | + $img = new Image("{$rel}/none.png"); | |
| 96 | + $test->assertEquals('UNSUPPORTED', $img->type); | |
| 97 | + $test->assertEquals(0, $img->bytes); | |
| 98 | + // テンプレートがfullInfoを読んでも落ちない事 | |
| 99 | + $test->assertEquals(0, $img->fullInfo->width); | |
| 100 | + $test->assertEquals('UNSUPPORTED', $img->fullInfo->type); | |
| 101 | + }); | |
| 102 | + | |
| 103 | + $test->it('未対応の形式は例外のはず', function($test) use ($rel): void { | |
| 104 | + // 拡張子ではなく中身で判断する | |
| 105 | + $test->assertThrows(fn() => new Image("{$rel}/fake.png"), \RuntimeException::class); | |
| 106 | + }); | |
| 107 | +}); | |
| 108 | + | |
| 109 | +foreach (glob("{$dir}/*") ?: [] as $f) @unlink($f); | |
| 110 | +@rmdir($dir); | |
| 111 | + | |
| 112 | +$test->printSummary(); |
| @@ -0,0 +1,137 @@ | ||
| 1 | +<?php | |
| 2 | +/************************************************************* | |
| 3 | +# 076 License | |
| 4 | + | |
| 5 | +Copyright (c) テクニカル諏訪子 | |
| 6 | + | |
| 7 | +Permission is hereby granted to any person obtaining a copy of the software | |
| 8 | +Little Beast (the "Software") to use, modify, merge, copy, publish, distribute, | |
| 9 | +sublicense, and/or sell copies of the Software, subject to the following conditions: | |
| 10 | + | |
| 11 | + 1. **Origin Attribution**: | |
| 12 | + - You must not misrepresent the origin of the Software; you must not claim | |
| 13 | + you created the original Software. | |
| 14 | + - If the Software is used in a product, you must either: | |
| 15 | + a. Provide clear attribution in the product's documentation, user interface, | |
| 16 | + or other visible areas, **OR** | |
| 17 | + b. Pay the original developers a fee they specify in writing. | |
| 18 | + 2. **Usage Restriction**: | |
| 19 | + - The Software, or any derivative works, dependencies, or libraries | |
| 20 | + incorporating it, must not be used for censorship or to suppress freedom of | |
| 21 | + speech, expression, or creativity. Prohibited uses include, but are not | |
| 22 | + limited to: | |
| 23 | + - Censorship of so-called "hate speech", visuals, non-mainstream opinions, | |
| 24 | + ideas, or objective reality. | |
| 25 | + - Tools or systems designed to restrict access to information or | |
| 26 | + artistic works. | |
| 27 | + 3. **Notice Preservation**: | |
| 28 | + - This license and the above copyright notice must remain intact in all copies | |
| 29 | + of the source code. | |
| 30 | + | |
| 31 | +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, | |
| 32 | +INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A | |
| 33 | +PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT | |
| 34 | +HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF | |
| 35 | +CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE | |
| 36 | +OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. | |
| 37 | + */ | |
| 38 | +namespace Std\Test; | |
| 39 | + | |
| 40 | +require_once __DIR__.'/../../../autoload.php'; | |
| 41 | + | |
| 42 | +use Std\Lib\Tester; | |
| 43 | +use Std\Lib\Latex; | |
| 44 | + | |
| 45 | +$test = new Tester([ | |
| 46 | + 'colorOutput' => true, | |
| 47 | + 'verboseOutput' => true | |
| 48 | +]); | |
| 49 | + | |
| 50 | +// <math>の中身だけ | |
| 51 | +$inner = function(string $tex, bool $display = false): string { | |
| 52 | + $html = (new Latex())->render($tex, $display); | |
| 53 | + return preg_replace('#^<math[^>]*>|</math>$#', '', $html); | |
| 54 | +}; | |
| 55 | + | |
| 56 | +$test->describe('LaTeXからMathMLへの変換', function($test) use ($inner): void { | |
| 57 | + $test->it('<math>要素で囲み、元のLaTeXをalttextに入れるはず', function($test): void { | |
| 58 | + $test->assertEquals('<math alttext="x"><mi>x</mi></math>', (new Latex())->render('x')); | |
| 59 | + $test->assertEquals('<math display="block" alttext="x"><mi>x</mi></math>', (new Latex())->render('x', true)); | |
| 60 | + $test->assertStringContains('alttext="a < b"', (new Latex())->render('a < b')); | |
| 61 | + }); | |
| 62 | + | |
| 63 | + $test->it('識別子・数字・演算子を区別するはず', function($test) use ($inner): void { | |
| 64 | + $test->assertEquals('<mrow><mi>a</mi><mo>+</mo><mn>12.5</mn></mrow>', $inner('a + 12.5')); | |
| 65 | + $test->assertEquals('<mrow><mi>a</mi><mo><</mo><mi>b</mi></mrow>', $inner('a < b')); | |
| 66 | + }); | |
| 67 | + | |
| 68 | + $test->it('上付き・下付きを変換するはず', function($test) use ($inner): void { | |
| 69 | + $test->assertEquals('<msup><mi>x</mi><mn>2</mn></msup>', $inner('x^2')); | |
| 70 | + $test->assertEquals('<msubsup><mi>a</mi><mi>i</mi><mn>2</mn></msubsup>', $inner('a_i^2')); | |
| 71 | + }); | |
| 72 | + | |
| 73 | + $test->it('分数と根号を変換するはず', function($test) use ($inner): void { | |
| 74 | + $test->assertEquals('<mfrac><mrow><mi>a</mi></mrow><mrow><mi>b</mi></mrow></mfrac>', $inner('\frac{a}{b}')); | |
| 75 | + $test->assertEquals('<msqrt><mrow><mi>x</mi></mrow></msqrt>', $inner('\sqrt{x}')); | |
| 76 | + $test->assertEquals('<mroot><mrow><mi>x</mi></mrow><mn>3</mn></mroot>', $inner('\sqrt[3]{x}')); | |
| 77 | + }); | |
| 78 | + | |
| 79 | + $test->it('ギリシャ文字と記号を変換するはず', function($test) use ($inner): void { | |
| 80 | + $test->assertEquals('<mrow><mi>α</mi><mo>+</mo><mi>β</mi></mrow>', $inner('\alpha + \beta')); | |
| 81 | + $test->assertStringContains('<mo>→</mo>', $inner('x \to 0')); | |
| 82 | + $test->assertStringContains('<mo>≤</mo>', $inner('a \leq b')); | |
| 83 | + }); | |
| 84 | + | |
| 85 | + $test->it('関数名を立体にし、細い空白を入れるはず', function($test) use ($inner): void { | |
| 86 | + $test->assertEquals('<mrow><mi>sin</mi><mspace width="0.1667em"></mspace><mi>x</mi></mrow>', $inner('\sin x')); | |
| 87 | + $test->assertStringContains('<mi>tr</mi>', $inner('\operatorname{tr} A')); | |
| 88 | + }); | |
| 89 | + | |
| 90 | + $test->it('大型演算子と極限を変換するはず', function($test) use ($inner): void { | |
| 91 | + $test->assertStringContains('<msubsup><mo largeop="true" movablelimits="true">∑</mo>', $inner('\sum_{i=1}^{n} i')); | |
| 92 | + $test->assertStringContains('<msub><mo movablelimits="true" form="prefix">lim</mo>', $inner('\lim_{x \to 0} f')); | |
| 93 | + }); | |
| 94 | + | |
| 95 | + $test->it('行列と括弧を変換するはず', function($test) use ($inner): void { | |
| 96 | + $m = $inner('\begin{pmatrix} 1 & 2 \\\\ 3 & 4 \end{pmatrix}'); | |
| 97 | + $test->assertStringContains('<mtable><mtr><mtd><mn>1</mn></mtd><mtd><mn>2</mn></mtd></mtr><mtr><mtd><mn>3</mn></mtd><mtd><mn>4</mn></mtd></mtr></mtable>', $m); | |
| 98 | + $test->assertStringContains('form="prefix">(</mo>', $m); | |
| 99 | + $test->assertStringContains('form="postfix">)</mo>', $m); | |
| 100 | + $test->assertStringContains('form="prefix">(</mo><mi>x</mi>', $inner('\left( x \right)')); | |
| 101 | + }); | |
| 102 | + | |
| 103 | + $test->it('文字の種類とアクセントを変換するはず', function($test) use ($inner): void { | |
| 104 | + $test->assertEquals('<mrow><mi>𝐯</mi></mrow>', $inner('\mathbf{v}')); | |
| 105 | + $test->assertStringContains('<mi mathvariant="normal">d</mi>', $inner('\mathrm{d}x')); | |
| 106 | + $test->assertStringContains('<mover accent="true">', $inner('\vec{v}')); | |
| 107 | + $test->assertStringContains('<mtext>if </mtext>', $inner('\text{if } x')); | |
| 108 | + }); | |
| 109 | + | |
| 110 | + $test->it('HTMLをエスケープするはず', function($test) use ($inner): void { | |
| 111 | + $out = $inner('\text{<script>alert(1)</script>}'); | |
| 112 | + $test->assertFalse(str_contains($out, '<script>')); | |
| 113 | + $test->assertStringContains('<script>', $out); | |
| 114 | + }); | |
| 115 | + | |
| 116 | + $test->it('未対応のコマンドを報告するはず', function($test): void { | |
| 117 | + $l = new Latex(); | |
| 118 | + $test->assertStringContains('<merror><mtext>\foo</mtext></merror>', $l->render('\foo')); | |
| 119 | + $test->assertEquals(['\foo'], $l->getErrors()); | |
| 120 | + $l->render('x'); | |
| 121 | + $test->assertEquals([], $l->getErrors()); | |
| 122 | + $l->render('\left( x'); | |
| 123 | + $test->assertCount(1, $l->getErrors()); | |
| 124 | + }); | |
| 125 | + | |
| 126 | + $test->it('不完全な入力でも止まらないはず', function($test): void { | |
| 127 | + $l = new Latex(); | |
| 128 | + foreach (['\frac{a', '{{{', '}', '\sqrt[', '^^', 'x^', '\\', '\begin{pmatrix} 1', str_repeat('{', 2000).'x'] as $tex) { | |
| 129 | + $start = microtime(true); | |
| 130 | + $out = $l->render($tex); | |
| 131 | + $test->assertTrue(str_starts_with($out, '<math') && str_ends_with($out, '</math>'), "「{$tex}」"); | |
| 132 | + $test->assertTrue(microtime(true) - $start < 1, "「".substr($tex, 0, 20)."」に時間が掛かり過ぎ"); | |
| 133 | + } | |
| 134 | + }); | |
| 135 | +}); | |
| 136 | + | |
| 137 | +$test->printSummary(); |
| @@ -0,0 +1,145 @@ | ||
| 1 | +<?php | |
| 2 | +/************************************************************* | |
| 3 | +# 076 License | |
| 4 | + | |
| 5 | +Copyright (c) テクニカル諏訪子 | |
| 6 | + | |
| 7 | +Permission is hereby granted to any person obtaining a copy of the software | |
| 8 | +Little Beast (the "Software") to use, modify, merge, copy, publish, distribute, | |
| 9 | +sublicense, and/or sell copies of the Software, subject to the following conditions: | |
| 10 | + | |
| 11 | + 1. **Origin Attribution**: | |
| 12 | + - You must not misrepresent the origin of the Software; you must not claim | |
| 13 | + you created the original Software. | |
| 14 | + - If the Software is used in a product, you must either: | |
| 15 | + a. Provide clear attribution in the product's documentation, user interface, | |
| 16 | + or other visible areas, **OR** | |
| 17 | + b. Pay the original developers a fee they specify in writing. | |
| 18 | + 2. **Usage Restriction**: | |
| 19 | + - The Software, or any derivative works, dependencies, or libraries | |
| 20 | + incorporating it, must not be used for censorship or to suppress freedom of | |
| 21 | + speech, expression, or creativity. Prohibited uses include, but are not | |
| 22 | + limited to: | |
| 23 | + - Censorship of so-called "hate speech", visuals, non-mainstream opinions, | |
| 24 | + ideas, or objective reality. | |
| 25 | + - Tools or systems designed to restrict access to information or | |
| 26 | + artistic works. | |
| 27 | + 3. **Notice Preservation**: | |
| 28 | + - This license and the above copyright notice must remain intact in all copies | |
| 29 | + of the source code. | |
| 30 | + | |
| 31 | +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, | |
| 32 | +INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A | |
| 33 | +PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT | |
| 34 | +HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF | |
| 35 | +CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE | |
| 36 | +OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. | |
| 37 | + */ | |
| 38 | +namespace Std\Test; | |
| 39 | + | |
| 40 | +require_once __DIR__.'/../../../autoload.php'; | |
| 41 | + | |
| 42 | +use Std\Lib\Tester; | |
| 43 | +use Std\Lib\Mailer; | |
| 44 | +use Std\Test\Helper\MockServer; | |
| 45 | + | |
| 46 | +$test = new Tester([ | |
| 47 | + 'colorOutput' => true, | |
| 48 | + 'verboseOutput' => true | |
| 49 | +]); | |
| 50 | + | |
| 51 | +$test->describe('メーラー(偽物のSMTPサーバー)', function($test): void { | |
| 52 | + $log = tempnam(sys_get_temp_dir(), 'lbsmtp_'); | |
| 53 | + $srv = MockServer::start('smtp-server.php', [$log]); | |
| 54 | + if ($srv === null) { | |
| 55 | + $test->skipAll('偽物のサーバーを起動出来ない(proc_openが無効?)'); | |
| 56 | + } | |
| 57 | + | |
| 58 | + $info = fn() => ['host' => '127.0.0.1', 'port' => $srv->port, 'user' => 'me@076.moe', 'pass' => 'ひみつ', 'from' => 'me@076.moe']; | |
| 59 | + $send = function(callable $fn) use ($info, $log): string { | |
| 60 | + file_put_contents($log, ''); | |
| 61 | + $mail = new Mailer($info()); | |
| 62 | + $mail->connect(); | |
| 63 | + try { | |
| 64 | + $mail->authenticate(); | |
| 65 | + $fn($mail); | |
| 66 | + } finally { | |
| 67 | + $mail->disconnect(); | |
| 68 | + } | |
| 69 | + return (string)file_get_contents($log); | |
| 70 | + }; | |
| 71 | + | |
| 72 | + $test->it('MAILER_ENABLEDが無効なら設定なしで作れないはず', function($test): void { | |
| 73 | + if (MAILER_ENABLED) { | |
| 74 | + $test->assertNotNull(new Mailer()); | |
| 75 | + return; | |
| 76 | + } | |
| 77 | + $test->assertThrows(fn() => new Mailer(), \Exception::class); | |
| 78 | + }); | |
| 79 | + | |
| 80 | + $test->it('認証してメールを送れるはず', function($test) use ($send): void { | |
| 81 | + $out = $send(fn($m) => $m->send('you@example.com', 'テスト件名', "こんにちは\n2行目", 'あなた')); | |
| 82 | + | |
| 83 | + $test->assertStringContains("C: AUTH LOGIN\r\n", $out); | |
| 84 | + $test->assertStringContains('C: '.base64_encode('me@076.moe')."\r\n", $out); | |
| 85 | + $test->assertStringContains('C: '.base64_encode('ひみつ')."\r\n", $out); | |
| 86 | + $test->assertStringContains("C: MAIL FROM: <me@076.moe>\r\n", $out); | |
| 87 | + $test->assertStringContains("C: RCPT TO: <you@example.com>\r\n", $out); | |
| 88 | + $test->assertStringContains("C: QUIT\r\n", $out); | |
| 89 | + | |
| 90 | + $test->assertStringContains('Subject: '.mb_encode_mimeheader('テスト件名', 'UTF-8', 'Q'), $out); | |
| 91 | + $test->assertStringContains('To: '.mb_encode_mimeheader('あなた', 'UTF-8', 'Q').' <you@example.com>', $out); | |
| 92 | + $test->assertTrue((bool)preg_match('/^Message-ID: <[0-9a-f]{32}@076\.moe>\r$/m', $out), 'Message-IDが無い'); | |
| 93 | + $test->assertStringContains('Content-Transfer-Encoding: quoted-printable', $out); | |
| 94 | + }); | |
| 95 | + | |
| 96 | + $test->it('EHLOでは自分のホスト名を名乗るはず', function($test) use ($send): void { | |
| 97 | + $out = $send(fn($m) => $m->send('you@example.com', 'x', 'x')); | |
| 98 | + $test->assertStringContains('C: EHLO '.(gethostname() ?: 'localhost')."\r\n", $out); | |
| 99 | + $test->assertFalse(str_contains($out, 'C: EHLO 127.0.0.1')); | |
| 100 | + }); | |
| 101 | + | |
| 102 | + $test->it('本文をquoted-printableでエンコードし、改行をCRLFに揃えるはず', function($test) use ($send): void { | |
| 103 | + $out = $send(fn($m) => $m->send('you@example.com', 'x', "日本語\n二行目")); | |
| 104 | + preg_match("/\r\n\r\n(.*)END DATA/s", $out, $m); | |
| 105 | + $body = quoted_printable_decode($m[1] ?? ''); | |
| 106 | + $test->assertEquals("日本語\r\n二行目\r\n", $body); | |
| 107 | + }); | |
| 108 | + | |
| 109 | + $test->it('「.」で始まる行をドットスタッフィングするはず', function($test) use ($send): void { | |
| 110 | + // 以前は「.」だけの行で本文が途中で終わった | |
| 111 | + $out = $send(fn($m) => $m->send('you@example.com', 'x', "前\n.\n.後ろ\n終わり")); | |
| 112 | + $test->assertStringContains("\r\n..\r\n..=E5=BE=8C", $out); | |
| 113 | + $test->assertStringContains('終わり', quoted_printable_decode($out)); | |
| 114 | + }); | |
| 115 | + | |
| 116 | + $test->it('ヘッダー注入を拒否するはず', function($test) use ($info): void { | |
| 117 | + $mail = new Mailer($info()); | |
| 118 | + $mail->connect(); | |
| 119 | + try { | |
| 120 | + $test->assertThrows(fn() => $mail->send("you@example.com>\r\nRCPT TO: <evil@example.com", 'x', 'x'), \InvalidArgumentException::class); | |
| 121 | + $test->assertThrows(fn() => $mail->send('you@example.com', "件名\r\nBcc: evil@example.com", 'x'), \InvalidArgumentException::class); | |
| 122 | + $test->assertThrows(fn() => $mail->send('you@example.com', 'x', 'x', "名前\nBcc: x"), \InvalidArgumentException::class); | |
| 123 | + $test->assertThrows(fn() => $mail->send('not-an-address', 'x', 'x'), \InvalidArgumentException::class); | |
| 124 | + } finally { | |
| 125 | + $mail->disconnect(); | |
| 126 | + } | |
| 127 | + }); | |
| 128 | + | |
| 129 | + $test->it('拒否された宛先は例外になるはず', function($test) use ($send): void { | |
| 130 | + $test->assertThrows(fn() => $send(fn($m) => $m->send('reject@example.com', 'x', 'x')), \Exception::class); | |
| 131 | + }); | |
| 132 | + | |
| 133 | + $test->it('接続出来ない場合は例外になるはず', function($test): void { | |
| 134 | + $probe = stream_socket_server('tcp://127.0.0.1:0'); | |
| 135 | + $port = (int)substr(strrchr(stream_socket_get_name($probe, false), ':'), 1); | |
| 136 | + fclose($probe); | |
| 137 | + $mail = new Mailer(['host' => '127.0.0.1', 'port' => $port, 'from' => 'me@076.moe']); | |
| 138 | + $test->assertThrows(fn() => $mail->connect(), \Exception::class); | |
| 139 | + }); | |
| 140 | + | |
| 141 | + $srv?->stop(); | |
| 142 | + @unlink($log); | |
| 143 | +}); | |
| 144 | + | |
| 145 | +$test->printSummary(); |
| @@ -41,58 +41,145 @@ | ||
| 41 | 41 | |
| 42 | 42 | use Result; |
| 43 | 43 | use Std\Lib\Tester; |
| 44 | +use Std\Lib\Curl; | |
| 44 | 45 | use Std\Lib\Openprovider; |
| 46 | +use Std\Test\Helper\MockServer; | |
| 45 | 47 | |
| 46 | 48 | $test = new Tester([ |
| 47 | 49 | 'colorOutput' => true, |
| 48 | 50 | 'verboseOutput' => true |
| 49 | 51 | ]); |
| 50 | 52 | |
| 51 | -$test->describe('認証', function($test): void { | |
| 52 | - $test->it('トークンを受け取れるはず', function($test): void { | |
| 53 | - $op = new OpenProvider(); | |
| 53 | +// 偽物のAPIサーバーに繋ぐOpenprovider(本物のキャッシュは使わない) | |
| 54 | +$cacheName = 'lbtest_op_'.bin2hex(random_bytes(4)); | |
| 55 | +$make = fn(string $baseUrl) => new class($baseUrl, $cacheName) extends Openprovider { | |
| 56 | + public function __construct(string $baseUrl, string $cacheDir) { | |
| 57 | + $this->BASEURL = $baseUrl; | |
| 58 | + $this->cacheDir = $cacheDir; | |
| 59 | + parent::__construct(); | |
| 60 | + } | |
| 61 | + protected function setupCurl(string $uri, string $resType = 'GET', array $payload = []): Curl { | |
| 62 | + return parent::setupCurl($uri, $resType, $payload)->setBlockPrivateAddresses(false)->setTimeout(5); | |
| 63 | + } | |
| 64 | +}; | |
| 65 | + | |
| 66 | +$test->describe('Openprovider(偽物のAPIサーバー)', function($test) use ($make): void { | |
| 67 | + if (!OPENPROVIDER_ENABLED) { | |
| 68 | + $test->skipAll('OPENPROVIDER_ENABLEDが無効'); | |
| 69 | + } | |
| 70 | + $srv = OPENPROVIDER_ENABLED ? MockServer::start('http-server.php') : null; | |
| 71 | + if (OPENPROVIDER_ENABLED && $srv === null) { | |
| 72 | + $test->skipAll('偽物のサーバーを起動出来ない(proc_openが無効?)'); | |
| 73 | + } | |
| 74 | + $base = $srv ? $srv->url('/v1beta') : ''; | |
| 75 | + // 失敗するテストではassert()を止める(DEBUG_MODEだとAssertionErrorになる) | |
| 76 | + $quiet = function(callable $fn) { | |
| 77 | + $old = ini_get('zend.assertions'); | |
| 78 | + if ($old !== '-1') ini_set('zend.assertions', '0'); | |
| 79 | + // assert_not_null()等の「assert機能性が無効です」の警告も出さない | |
| 80 | + set_error_handler(fn(int $no, string $msg) => $no === E_USER_WARNING && str_contains($msg, 'assert')); | |
| 81 | + try { | |
| 82 | + return $fn(); | |
| 83 | + } finally { | |
| 84 | + restore_error_handler(); | |
| 85 | + if ($old !== '-1') ini_set('zend.assertions', $old); | |
| 86 | + } | |
| 87 | + }; | |
| 88 | + $count = function(string $path) use ($srv): int { | |
| 89 | + $c = (new Curl($srv->url('/count?path='.urlencode($path))))->setBlockPrivateAddresses(false); | |
| 90 | + $c->execute(); | |
| 91 | + return (int)(json_decode($c->getResponseBody(), true)['count'] ?? -1); | |
| 92 | + }; | |
| 93 | + | |
| 94 | + $test->it('ログインしてトークンを受け取れるはず', function($test) use ($make, $base): void { | |
| 95 | + $op = $make($base); | |
| 54 | 96 | $res = $op->login(); |
| 97 | + $test->assertTrue($res->isSuccess); | |
| 98 | + $test->assertEquals('tok123', $res->data['data']['token']); | |
| 99 | + $test->assertArrayHasKey('last_auth', $res->data); | |
| 100 | + }); | |
| 55 | 101 | |
| 56 | - $expect = Result::Success(); | |
| 57 | - $test->assertEquals($expect, $res); | |
| 102 | + $test->it('2回目はキャッシュのトークンを使うはず', function($test) use ($make, $base, $count): void { | |
| 103 | + $before = $count('/v1beta/auth/login'); | |
| 104 | + $res = $make($base)->login(); | |
| 105 | + $test->assertTrue($res->isSuccess); | |
| 106 | + $test->assertEquals($before, $count('/v1beta/auth/login')); | |
| 58 | 107 | }); |
| 59 | 108 | |
| 60 | - $test->it('トークンを受け取れるはず', function($test): void {}); | |
| 61 | -}); | |
| 109 | + $test->it('ログインの失敗はエラーの説明を返すはず', function($test) use ($make, $srv, $quiet): void { | |
| 110 | + // 以前は配列をResult::Error()に渡してTypeErrorになった | |
| 111 | + $op = $make($srv->url('/bad/v1beta')); | |
| 112 | + $op->deleteCache('login'); | |
| 113 | + $res = $quiet(fn() => $op->login()); | |
| 114 | + $test->assertFalse($res->isSuccess); | |
| 115 | + $test->assertEquals('Authentication/Authorization Failed', $res->message); | |
| 116 | + }); | |
| 62 | 117 | |
| 63 | -$test->describe('ドメイン', function($test): void { | |
| 64 | - $test->it('ドメイン名があるかのうか確認出来るはず', function($test): void { | |
| 65 | - $op = new OpenProvider(); | |
| 118 | + $test->it('ドメインがあるかどうか確認出来るはず', function($test) use ($make, $base): void { | |
| 119 | + $op = $make($base); | |
| 66 | 120 | $op->login(); |
| 67 | - $res = $op->checkDomainAvailable(['076.moe', '076.xxx', '076.nigger']); | |
| 121 | + $res = $op->checkDomainAvailable(['076.moe', '076.xxx', '076.co.jp']); | |
| 122 | + $test->assertTrue($res->isSuccess); | |
| 123 | + $test->assertEquals([ | |
| 124 | + ['domain' => '076.moe', 'status' => 'active', 'reason' => 'In use'], | |
| 125 | + ['domain' => '076.xxx', 'status' => 'free'], | |
| 126 | + // 以前は「co.jp」の様なTLDで「不正なドメイン形」になった | |
| 127 | + ['domain' => '076.co.jp', 'status' => 'free'], | |
| 128 | + ], $res->data); | |
| 129 | + }); | |
| 68 | 130 | |
| 69 | - $expect = [ | |
| 70 | - 0 => [ | |
| 71 | - 'domain' => '076.nigger', | |
| 72 | - 'status' => 'active', | |
| 73 | - 'reason' => 'Your domain request contains an invalid extension!', | |
| 74 | - ], | |
| 75 | - 1 => [ | |
| 76 | - 'domain' => '076.moe', | |
| 77 | - 'status' => 'active', | |
| 78 | - 'reason' => 'In use', | |
| 79 | - ], | |
| 80 | - 2 => [ | |
| 81 | - 'domain' => '076.xxx', | |
| 82 | - 'status' => 'free', | |
| 83 | - ], | |
| 84 | - ]; | |
| 85 | - $test->assertEquals($expect, $res->data); | |
| 131 | + $test->it('不正なドメインを拒否するはず', function($test) use ($make, $base): void { | |
| 132 | + $op = $make($base); | |
| 133 | + foreach (['076', '.moe', '076.'] as $d) { | |
| 134 | + $res = $op->checkDomainAvailable([$d]); | |
| 135 | + $test->assertFalse($res->isSuccess, "「{$d}」が拒否されなかった"); | |
| 136 | + $test->assertStringContains('不正なドメイン形', $res->message); | |
| 137 | + } | |
| 86 | 138 | }); |
| 87 | - | |
| 88 | - $test->it('ドメイン名(値段付き)があるかのうか確認出来るはず', function($test): void { | |
| 89 | - $op = new OpenProvider(); | |
| 139 | + | |
| 140 | + $test->it('ドメイン一覧を受け取り、キャッシュするはず', function($test) use ($make, $base, $count): void { | |
| 141 | + $op = $make($base); | |
| 90 | 142 | $op->login(); |
| 91 | - $res = $op->checkDomainAvailable(['076.moe'], true)[0]['price']['product']; | |
| 143 | + $res = $op->listDomains(); | |
| 144 | + $test->assertTrue($res->isSuccess); | |
| 145 | + $test->assertEquals(1, $res->data[0]['id']); | |
| 92 | 146 | |
| 93 | - $expect = true; | |
| 94 | - $test->assertEquals($expect, $res->isSuccess); | |
| 147 | + $before = $count('/v1beta/domains'); | |
| 148 | + $test->assertTrue($op->listDomains()->isSuccess); | |
| 149 | + $test->assertEquals($before, $count('/v1beta/domains')); | |
| 95 | 150 | }); |
| 151 | + | |
| 152 | + $test->it('トークンが無いとエラーになるはず', function($test) use ($make, $base, $quiet): void { | |
| 153 | + $op = $make($base); | |
| 154 | + $op->deleteCache('listdomains'); | |
| 155 | + // login()しない | |
| 156 | + $res = $quiet(fn() => $op->listDomains()); | |
| 157 | + $test->assertFalse($res->isSuccess); | |
| 158 | + }); | |
| 159 | + | |
| 160 | + $test->it('JSONではないエラーレスポンスでも落ちないはず', function($test) use ($make, $base, $quiet): void { | |
| 161 | + $op = $make($base.'/gateway?'); | |
| 162 | + $res = $quiet(fn() => $op->listTlds()); | |
| 163 | + $test->assertFalse($res->isSuccess); | |
| 164 | + }); | |
| 165 | + | |
| 166 | + $test->it('接続出来ない場合はエラーになるはず', function($test) use ($make, $quiet): void { | |
| 167 | + $probe = stream_socket_server('tcp://127.0.0.1:0'); | |
| 168 | + $port = (int)substr(strrchr(stream_socket_get_name($probe, false), ':'), 1); | |
| 169 | + fclose($probe); | |
| 170 | + $op = $make("http://127.0.0.1:{$port}/v1beta"); | |
| 171 | + $op->deleteCache('login'); | |
| 172 | + $res = $quiet(fn() => $op->login()); | |
| 173 | + $test->assertFalse($res->isSuccess); | |
| 174 | + $test->assertStringContains('CURLの実行に失敗', $res->message); | |
| 175 | + }); | |
| 176 | + | |
| 177 | + $srv?->stop(); | |
| 96 | 178 | }); |
| 97 | 179 | |
| 98 | -$test->printSummary(); | |
| 180 | +// テスト用のキャッシュを削除する | |
| 181 | +$dir = ROOT.'/data/cache/'.$cacheName; | |
| 182 | +foreach (glob("{$dir}/*") ?: [] as $f) @unlink($f); | |
| 183 | +@rmdir($dir); | |
| 184 | + | |
| 185 | +$test->printSummary(); |
| @@ -107,11 +107,51 @@ | ||
| 107 | 107 | $test->assertEquals('<span class="hl-string">"int 42"</span>', $html); |
| 108 | 108 | }); |
| 109 | 109 | |
| 110 | + $test->it('言語毎の特有の構文を分類するはず', function($test): void { | |
| 111 | + $cases = [ | |
| 112 | + ['swift', '/* a /* b */ c */ let x', '<span class="hl-comment">/* a /* b */ c */</span> <span class="hl-keyword">let</span>'], | |
| 113 | + ['csharp', 'var p = @"C:\\dir";', '<span class="hl-string">@"C:\\dir"</span>'], | |
| 114 | + ['csharp', 'async Task', '<span class="hl-keyword">async</span> <span class="hl-type">Task</span>'], | |
| 115 | + ['objc', '@interface Foo : NSObject', '<span class="hl-keyword">@interface</span> Foo : <span class="hl-type">NSObject</span>'], | |
| 116 | + ['slang', 'interface IFoo { float3 f(); }', '<span class="hl-type">float3</span> <span class="hl-function">f</span>()'], | |
| 117 | + ['hlsl', 'Texture2D<float4> t : register(t0);', '<span class="hl-type">Texture2D</span><<span class="hl-type">float4</span>>'], | |
| 118 | + ['glsl', 'uniform sampler2D tex;', '<span class="hl-keyword">uniform</span> <span class="hl-type">sampler2D</span>'], | |
| 119 | + ['go', 'return nil', '<span class="hl-keyword">return</span> <span class="hl-constant">nil</span>'], | |
| 120 | + ['cpp', 'template<typename T>', '<span class="hl-keyword">template</span><<span class="hl-keyword">typename</span> T>'], | |
| 121 | + ['c', '#include <stdio.h>', '<span class="hl-preprocessor">#include</span> <span class="hl-string"><stdio.h></span>'], | |
| 122 | + ['asm-x8664', 'mov rax, [rbp]', '<span class="hl-instruction">mov</span> <span class="hl-register">rax</span>, [<span class="hl-register">rbp</span>]'], | |
| 123 | + ['asm-6502', 'lda #$FF', '<span class="hl-instruction">lda</span> <span class="hl-number">#$FF</span>'], | |
| 124 | + ]; | |
| 125 | + foreach ($cases as [$lang, $code, $expect]) { | |
| 126 | + $test->assertStringContains($expect, (new Syntax($lang))->colorize($code), "{$lang}:{$code}"); | |
| 127 | + } | |
| 128 | + }); | |
| 129 | + | |
| 130 | + $test->it('空のコードとCRLFを扱えるはず', function($test): void { | |
| 131 | + $test->assertEquals('', (new Syntax('c'))->colorize('')); | |
| 132 | + // CRLFはLFに揃える(行コメントに「\r」が入らない様に) | |
| 133 | + $html = (new Syntax('c'))->colorize("int a; // x\r\nint b;\r\n"); | |
| 134 | + $test->assertEquals("int a; // x\nint b;\n", html_entity_decode(strip_tags($html), ENT_QUOTES | ENT_HTML5, 'UTF-8')); | |
| 135 | + $test->assertStringContains('<span class="hl-comment">// x</span>', $html); | |
| 136 | + }); | |
| 137 | + | |
| 138 | + $test->it('長いコードでも時間が掛からないはず', function($test): void { | |
| 139 | + $code = str_repeat("for (int i = 0; i < 10; i++) { x += \"s\"; } /* c */ // d\n", 2000); | |
| 140 | + $start = microtime(true); | |
| 141 | + $test->assertTrue(syntaxRoundtrip('cpp', $code)); | |
| 142 | + $test->assertTrue(microtime(true) - $start < 5, '時間が掛かり過ぎ'); | |
| 143 | + }); | |
| 144 | + | |
| 110 | 145 | $test->it('別名を解決するはず', function($test): void { |
| 111 | 146 | $test->assertEquals('cpp', Syntax::resolve('c++')); |
| 112 | 147 | $test->assertEquals('csharp', Syntax::resolve('C#')); |
| 113 | 148 | $test->assertEquals('asm-aarch64', Syntax::resolve('arm64')); |
| 114 | 149 | $test->assertFalse(Syntax::isSupported('sh')); |
| 150 | + $test->assertEquals('objc', Syntax::resolve('objective-c')); | |
| 151 | + $test->assertEquals('asm-x8664', Syntax::resolve('nasm')); | |
| 152 | + $test->assertEquals('glsl', Syntax::resolve('frag')); | |
| 153 | + $test->assertTrue(Syntax::isSupported('slang')); | |
| 154 | + $test->assertTrue(Syntax::isSupported('HLSL')); | |
| 115 | 155 | }); |
| 116 | 156 | }); |
| 117 | 157 |
| @@ -0,0 +1,157 @@ | ||
| 1 | +<?php | |
| 2 | +/************************************************************* | |
| 3 | +# 076 License | |
| 4 | + | |
| 5 | +Copyright (c) テクニカル諏訪子 | |
| 6 | + | |
| 7 | +Permission is hereby granted to any person obtaining a copy of the software | |
| 8 | +Little Beast (the "Software") to use, modify, merge, copy, publish, distribute, | |
| 9 | +sublicense, and/or sell copies of the Software, subject to the following conditions: | |
| 10 | + | |
| 11 | + 1. **Origin Attribution**: | |
| 12 | + - You must not misrepresent the origin of the Software; you must not claim | |
| 13 | + you created the original Software. | |
| 14 | + - If the Software is used in a product, you must either: | |
| 15 | + a. Provide clear attribution in the product's documentation, user interface, | |
| 16 | + or other visible areas, **OR** | |
| 17 | + b. Pay the original developers a fee they specify in writing. | |
| 18 | + 2. **Usage Restriction**: | |
| 19 | + - The Software, or any derivative works, dependencies, or libraries | |
| 20 | + incorporating it, must not be used for censorship or to suppress freedom of | |
| 21 | + speech, expression, or creativity. Prohibited uses include, but are not | |
| 22 | + limited to: | |
| 23 | + - Censorship of so-called "hate speech", visuals, non-mainstream opinions, | |
| 24 | + ideas, or objective reality. | |
| 25 | + - Tools or systems designed to restrict access to information or | |
| 26 | + artistic works. | |
| 27 | + 3. **Notice Preservation**: | |
| 28 | + - This license and the above copyright notice must remain intact in all copies | |
| 29 | + of the source code. | |
| 30 | + | |
| 31 | +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, | |
| 32 | +INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A | |
| 33 | +PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT | |
| 34 | +HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF | |
| 35 | +CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE | |
| 36 | +OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. | |
| 37 | + */ | |
| 38 | +namespace Std\Test; | |
| 39 | + | |
| 40 | +require_once __DIR__.'/../../../autoload.php'; | |
| 41 | + | |
| 42 | +use Std\Lib\Tester; | |
| 43 | +use Std\Lib\Template; | |
| 44 | + | |
| 45 | +$test = new Tester([ | |
| 46 | + 'colorOutput' => true, | |
| 47 | + 'verboseOutput' => true | |
| 48 | +]); | |
| 49 | + | |
| 50 | +// 一時的なviewディレクトリにテンプレートを作る | |
| 51 | +$viewDir = sys_get_temp_dir().'/lbview_'.bin2hex(random_bytes(6)); | |
| 52 | +mkdir("{$viewDir}/common", 0700, true); | |
| 53 | +$tmpl = function(string $src, array $vars = [], string $name = 't') use ($viewDir): string { | |
| 54 | + file_put_contents("{$viewDir}/{$name}.maron", $src); | |
| 55 | + $t = new Template('/', $viewDir); | |
| 56 | + foreach ($vars as $k => $v) $t->assign($k, $v); | |
| 57 | + return $t->fetch($name); | |
| 58 | +}; | |
| 59 | + | |
| 60 | +$test->describe('Maronテンプレート', function($test) use ($tmpl, $viewDir): void { | |
| 61 | + $test->it('変数をエスケープして出力するはず', function($test) use ($tmpl): void { | |
| 62 | + $test->assertEquals('<p><b>"x"</b></p>', $tmpl('<p>{{ $v }}</p>', ['v' => '<b>"x"</b>'])); | |
| 63 | + }); | |
| 64 | + | |
| 65 | + $test->it('{{{ }}}はそのまま出力するはず', function($test) use ($tmpl): void { | |
| 66 | + $test->assertEquals('<p><b>x</b></p>', $tmpl('<p>{{{ $v }}}</p>', ['v' => '<b>x</b>'])); | |
| 67 | + $test->assertEquals('<b>x</b>', $tmpl('{{{$v}}}', ['v' => '<b>x</b>'])); | |
| 68 | + }); | |
| 69 | + | |
| 70 | + $test->it('if・elif・elseを処理するはず', function($test) use ($tmpl): void { | |
| 71 | + $src = '{@ if ($n > 1) @}多{@ elif ($n === 1) @}一{@ else @}無{@ endif @}'; | |
| 72 | + $test->assertEquals('多', $tmpl($src, ['n' => 5])); | |
| 73 | + $test->assertEquals('一', $tmpl($src, ['n' => 1])); | |
| 74 | + $test->assertEquals('無', $tmpl($src, ['n' => 0])); | |
| 75 | + }); | |
| 76 | + | |
| 77 | + $test->it('foreachとforを処理するはず', function($test) use ($tmpl): void { | |
| 78 | + $test->assertEquals('a=1,b=2,', $tmpl('{@ foreach ($xs as $k => $v) @}{{ $k }}={{ $v }},{@ endforeach @}', ['xs' => ['a' => 1, 'b' => 2]])); | |
| 79 | + $test->assertEquals('012', $tmpl('{@ for ($i = 0; $i < 3; $i++) @}{{ $i }}{@ endfor @}')); | |
| 80 | + }); | |
| 81 | + | |
| 82 | + $test->it('入れ子のブロックを処理するはず', function($test) use ($tmpl): void { | |
| 83 | + $src = '{@ foreach ($xs as $x) @}{@ if ($x % 2) @}[{{ $x }}]{@ endif @}{@ endforeach @}'; | |
| 84 | + $test->assertEquals('[1][3]', $tmpl($src, ['xs' => [1, 2, 3, 4]])); | |
| 85 | + }); | |
| 86 | + | |
| 87 | + $test->it('変数の代入とPHPのコードを処理するはず', function($test) use ($tmpl): void { | |
| 88 | + $test->assertEquals('200', $tmpl('{$ $x = 100 * 2 $}{{ $x }}')); | |
| 89 | + $test->assertEquals('hi', $tmpl('{! echo "hi"; !}')); | |
| 90 | + }); | |
| 91 | + | |
| 92 | + $test->it('コメントは出力も実行もしないはず', function($test) use ($tmpl): void { | |
| 93 | + // 以前はコメントの中の{{ }}が先に変換され、「*/」でPHPのコメントが壊れた | |
| 94 | + $test->assertEquals('ab', $tmpl('a{# {{ $undefinedVar }} */ #}b')); | |
| 95 | + $test->assertEquals('ab', $tmpl("a{# 複数\n行 #}b")); | |
| 96 | + }); | |
| 97 | + | |
| 98 | + $test->it('インクルードを処理するはず', function($test) use ($tmpl, $viewDir): void { | |
| 99 | + file_put_contents("{$viewDir}/common/head.maron", '<h1>{{ $title }}</h1>'); | |
| 100 | + $test->assertEquals('<h1>T&T</h1>本文', $tmpl('{@ include(common/head) @}本文', ['title' => 'T&T'])); | |
| 101 | + }); | |
| 102 | + | |
| 103 | + $test->it('循環したインクルードで止まるはず', function($test) use ($tmpl, $viewDir): void { | |
| 104 | + file_put_contents("{$viewDir}/common/loop.maron", '{@ include(common/loop) @}'); | |
| 105 | + $test->assertThrows(fn() => $tmpl('{@ include(common/loop) @}'), \RuntimeException::class); | |
| 106 | + }); | |
| 107 | + | |
| 108 | + $test->it('無いテンプレートは例外のはず', function($test) use ($viewDir): void { | |
| 109 | + $test->assertThrows(fn() => (new Template('/', $viewDir))->fetch('nothing'), \RuntimeException::class); | |
| 110 | + $test->assertThrows(fn() => (new Template('/', $viewDir))->render('nothing'), \RuntimeException::class); | |
| 111 | + }); | |
| 112 | + | |
| 113 | + $test->it('「content」等の名前の変数も使えるはず', function($test) use ($tmpl): void { | |
| 114 | + // 以前はextract()の後に$content等が上書きされ、テンプレートのソースが表示された | |
| 115 | + $test->assertEquals('本文/パス', $tmpl('{{ $content }}/{{ $tmplPath }}', ['content' => '本文', 'tmplPath' => 'パス'])); | |
| 116 | + }); | |
| 117 | + | |
| 118 | + $test->it('カスタム関数を呼べるはず', function($test) use ($viewDir): void { | |
| 119 | + file_put_contents("{$viewDir}/f.maron", '[{@ shout(abc, def) @}]'); | |
| 120 | + $t = new Template('/', $viewDir); | |
| 121 | + $t->registerFunction('shout', fn($a, $b) => strtoupper($a.$b)); | |
| 122 | + $test->assertEquals('[ABCDEF]', $t->fetch('f')); | |
| 123 | + }); | |
| 124 | + | |
| 125 | + $test->it('CSSを追加出来るはず', function($test) use ($viewDir): void { | |
| 126 | + file_put_contents("{$viewDir}/css.maron", '{@ foreach ($custCss as $c) @}{{{ $c }}}{@ endforeach @}'); | |
| 127 | + $t = new Template('/', $viewDir); | |
| 128 | + $t->addCss('code'); | |
| 129 | + $test->assertStringContains('href="/static/style-code.css"', $t->fetch('css')); | |
| 130 | + }); | |
| 131 | + | |
| 132 | + $test->it('サブディレクトリ(多言語)のテンプレートを使えるはず', function($test) use ($viewDir): void { | |
| 133 | + mkdir("{$viewDir}/en"); | |
| 134 | + file_put_contents("{$viewDir}/en/index.maron", 'English'); | |
| 135 | + $test->assertEquals('English', (new Template('/en', $viewDir))->fetch('index')); | |
| 136 | + $test->assertEquals('English', (new Template('en/', $viewDir))->fetch('index')); | |
| 137 | + }); | |
| 138 | + | |
| 139 | + $test->it('例外が起きても一時ファイルを残さないはず', function($test) use ($tmpl): void { | |
| 140 | + $before = count(glob(sys_get_temp_dir().'/tmpl_*') ?: []); | |
| 141 | + try { | |
| 142 | + $tmpl('{! throw new \RuntimeException("x"); !}'); | |
| 143 | + } catch (\RuntimeException $e) {} | |
| 144 | + $test->assertEquals($before, count(glob(sys_get_temp_dir().'/tmpl_*') ?: [])); | |
| 145 | + }); | |
| 146 | +}); | |
| 147 | + | |
| 148 | +$rm = function(string $dir) use (&$rm): void { | |
| 149 | + foreach (scandir($dir) ?: [] as $f) { | |
| 150 | + if ($f === '.' || $f === '..') continue; | |
| 151 | + is_dir("{$dir}/{$f}") ? $rm("{$dir}/{$f}") : @unlink("{$dir}/{$f}"); | |
| 152 | + } | |
| 153 | + @rmdir($dir); | |
| 154 | +}; | |
| 155 | +$rm($viewDir); | |
| 156 | + | |
| 157 | +$test->printSummary(); |