ユニットテスト

守矢諏訪子 <suwako@076.moe>、2026-11-10 20:55
コミット 7208562c62eb49b16ef4c0b1f68b20aac9bd6367 ・親 f6cdbde ・ファイルを見る

14個のファイル、+1775 -156

@@ -175,6 +175,9 @@
175175 call_user_func($after);
176176 }
177177 } catch (\Throwable $e) {
178+ // 以前はerrorCountを増やさなかったので、「全てのテストに合格しました!」と表示された
179+ $this->errorCount++;
180+ $this->currentTest = '';
178181 $this->recordError(
179182 "テストケースのセットアップ/ティアダウンでエラー: ".$e->getMessage(),
180183 $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 @@
4343 use Std\Lib\Activitypub;
4444 use Site\Controller\BlogPost;
4545
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+]);
5150
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;
5754
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+};
6473
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+ }
6778
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';
7085
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+ });
7391
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+ });
76106
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']);
79119
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+ });
82126
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+ });
94134
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+ });
97141
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+ });
100154
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"];
103160
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']);
106167
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']);
110175 });
111176
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+ });
113185
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 @@
4141
4242 use Std\Lib\Tester;
4343 use Std\Lib\Curl;
44+use Std\Test\Helper\MockServer;
4445
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+]);
5050
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);
5653
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+ }
6258
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+ });
6971
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+ });
7377
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+ });
7883
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+ });
8091
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);
8697
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);
89103 });
90104
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+});
93111
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+ }
99117
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());
103123
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+ });
109131
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);
118140
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+ });
122148
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']);
127157 });
128158
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 &lt;stdio.h&gt;'], $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">&nbsp;</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+&amp;\n");
114+ $test->assertFalse(str_contains($html, '<script>'));
115+ $test->assertFalse(str_contains($html, '<b>'));
116+ $test->assertStringContains('&lt;script&gt;', $html);
117+ $test->assertStringContains('&amp;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 &lt; 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>&lt;</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('&lt;script&gt;', $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 @@
4141
4242 use Result;
4343 use Std\Lib\Tester;
44+use Std\Lib\Curl;
4445 use Std\Lib\Openprovider;
46+use Std\Test\Helper\MockServer;
4547
4648 $test = new Tester([
4749 'colorOutput' => true,
4850 'verboseOutput' => true
4951 ]);
5052
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);
5496 $res = $op->login();
97+ $test->assertTrue($res->isSuccess);
98+ $test->assertEquals('tok123', $res->data['data']['token']);
99+ $test->assertArrayHasKey('last_auth', $res->data);
100+ });
55101
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'));
58107 });
59108
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+ });
62117
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);
66120 $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+ });
68130
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+ }
86138 });
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);
90142 $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']);
92146
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'));
95150 });
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();
96178 });
97179
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 @@
107107 $test->assertEquals('<span class="hl-string">&quot;int 42&quot;</span>', $html);
108108 });
109109
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">@&quot;C:\\dir&quot;</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>&lt;<span class="hl-type">float4</span>&gt;'],
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>&lt;<span class="hl-keyword">typename</span> T&gt;'],
121+ ['c', '#include <stdio.h>', '<span class="hl-preprocessor">#include</span> <span class="hl-string">&lt;stdio.h&gt;</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+
110145 $test->it('別名を解決するはず', function($test): void {
111146 $test->assertEquals('cpp', Syntax::resolve('c++'));
112147 $test->assertEquals('csharp', Syntax::resolve('C#'));
113148 $test->assertEquals('asm-aarch64', Syntax::resolve('arm64'));
114149 $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'));
115155 });
116156 });
117157
@@ -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>&lt;b&gt;&quot;x&quot;&lt;/b&gt;</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&amp;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();