true, 'verboseOutput' => true ]); $followers = tempnam(sys_get_temp_dir(), 'lbfollow_'); $key = Rsa::generate(2048); // 外部との通信だけを偽物にする $make = fn(array $posts = []) => new class($posts, $followers, $key) extends Activitypub { public array $sent = []; public array $remote = []; public function __construct(array $posts, private string $file, private Rsa $key) { parent::__construct($posts); } protected function followersFile(): string { return $this->file; } protected function getInboxFromActor(string $actor): ?string { return $this->remote[$actor]['inbox'] ?? null; } public function fetchRemoteJson(string $url): ?array { return $this->remote[$url] ?? null; } protected function sendActivity(string $inboxUrl, array $activity): bool { $this->sent[] = [$inboxUrl, $activity]; return true; } public function sign(string $url, string $body, string $method = 'post'): ?array { return $this->signRequest($url, $body, $this->key, $method); } }; $test->describe('ActivityPub', function($test) use ($make, $followers, $key): void { if (!ACTIVITYPUB_ENABLED) { $test->skipAll('ACTIVITYPUB_ENABLEDが無効'); } $posts = ACTIVITYPUB_ENABLED ? (new BlogPost())->getPosts('/blog/', null) : []; $finger = ACTIVITYPUB_ENABLED ? json_decode($make()->getWebfinger(), true) : []; $domain = substr($finger['links'][0]['href'] ?? '', 0, -strlen('/ap/actor')); $feature = 'a8c04518-4181-4ec6-9ef0-3f88f23b84b6'; // /blog/feature-test $member = 'd49f60c7-6d85-4263-9d3f-c20025f2fa0d'; // /blog/memberonly-post // サンプルの投稿はLittleBeastのblog/にだけある(サイトでは公開されてしまうので置かない) $sample = is_file(ROOT.'/blog/feature-test.md'); $noSample = 'サンプルの投稿(blog/feature-test.md)が無い'; $remoteActor = 'https://1.1.1.1/users/alice'; $test->it('WebFingerを受け取れるはず', function($test) use ($finger, $domain): void { $host = parse_url($domain, PHP_URL_HOST); $test->assertEquals("acct:".FEDIINFO['actor']."@{$host}", $finger['subject']); $test->assertEquals('application/activity+json', $finger['links'][0]['type']); }); $test->it('このインスタンスのユーザーを受け取れるはず', function($test) use ($make, $domain): void { try { $json = json_decode($make()->getActor(), true); } catch (\Exception $e) { $test->assertStringContains('公開鍵', $e->getMessage()); return; // 公開鍵が読めない環境 } $test->assertEquals('Person', $json['type']); $test->assertEquals("{$domain}/ap/actor", $json['id']); $test->assertEquals("{$domain}/ap/inbox", $json['inbox']); $test->assertEquals("{$domain}/ap/outbox", $json['outbox']); $test->assertEquals("{$domain}/ap/followers", $json['followers']); $test->assertEquals("{$domain}/ap/actor#main-key", $json['publicKey']['id']); }); if ($sample) $test->it('公開投稿のアクティビティを受け取れるはず', function($test) use ($make, $posts, $feature, $domain): void { $json = json_decode($make($posts)->getActivity($feature), true); $test->assertEquals('Create', $json['type']); $test->assertEquals("{$domain}/ap/activities/create/{$feature}", $json['id']); $test->assertEquals('【マークダウン】関数', $json['object']['name']); $test->assertEquals('https://www.w3.org/ns/activitystreams#Public', $json['object']['to'][0]); // 投稿の日付(PHPのタイムゾーン)をUTCにする $test->assertEquals(gmdate('Y-m-d\TH:i:s\Z', strtotime('2025-11-07 20:33:42')), $json['published']); // タグと添付はNoteに付ける $test->assertEquals(['type' => 'Hashtag', 'name' => '#test'], $json['object']['tag'][0]); $test->assertFalse(isset($json['tag'])); $test->assertStringContains('/blog/feature-test', $json['object']['url']); if (is_file(ROOT.ass_path('article/o_53803618dc1691.28179609-orig.jpg'))) { $test->assertEquals('zBbw7K7L1dEwr6htZX5JevRv3oLtHwXQU4o5jT8jn9RKF', $json['object']['attachment'][0]['digestMultibase']); $test->assertEquals(ass_url('article/o_53803618dc1691.28179609-orig.jpg', $domain), $json['object']['attachment'][0]['url']); $test->assertEquals('image/jpeg', $json['object']['attachment'][0]['mediaType']); } }); else $test->skip('公開投稿のアクティビティを受け取れるはず', $noSample); $test->it('会員限定・存在しない投稿は返さないはず', function($test) use ($make, $posts, $member): void { $ap = $make($posts); $test->assertEquals('', $ap->getActivity($member)); $test->assertEquals('', $ap->getActivity('00000000-0000-0000-0000-000000000000')); $test->assertEquals('', $ap->getActivity('')); $test->assertEquals('', $ap->getObject($member)); }); if ($sample) $test->it('オブジェクト(Note)を受け取れるはず', function($test) use ($make, $posts, $feature, $domain): void { $json = json_decode($make($posts)->getObject($feature), true); $test->assertEquals('Note', $json['type']); $test->assertEquals("{$domain}/ap/objects/{$feature}", $json['id']); $test->assertTrue(isset($json['@context'])); }); else $test->skip('オブジェクト(Note)を受け取れるはず', $noSample); $test->it('アウトボックスは公開投稿だけを新しい順に返すはず', function($test) use ($make, $posts, $member): void { $json = json_decode($make($posts)->getOutbox(), true); $test->assertEquals('OrderedCollection', $json['type']); $test->assertEquals(count($json['orderedItems']), $json['totalItems']); $ids = array_map(fn($i) => basename($i['object']['id']), $json['orderedItems']); $test->assertFalse(in_array($member, $ids, true)); $test->assertFalse(in_array('1c0ac334-a017-4813-9137-939b483c08eb', $ids, true)); // staffonly $dates = array_map(fn($i) => $i['published'], $json['orderedItems']); $sorted = $dates; rsort($sorted); $test->assertEquals($sorted, $dates); }); $test->it('フォローを受け入れてAcceptを送るはず', function($test) use ($make, $followers, $domain, $remoteActor): void { file_put_contents($followers, ''); $ap = $make(); $ap->remote[$remoteActor] = ['inbox' => "{$remoteActor}/inbox"]; $follow = ['type' => 'Follow', 'id' => "{$remoteActor}#follow", 'actor' => $remoteActor, 'object' => "{$domain}/ap/actor"]; [$code] = $ap->handleInbox($follow); $test->assertEquals(200, $code); $test->assertEquals("{$remoteActor}\n", file_get_contents($followers)); $test->assertEquals("{$remoteActor}/inbox", $ap->sent[0][0]); $test->assertEquals('Accept', $ap->sent[0][1]['type']); $test->assertEquals($follow, $ap->sent[0][1]['object']); // 2回フォローしても1行だけ $ap->handleInbox($follow); $test->assertEquals("{$remoteActor}\n", file_get_contents($followers)); $list = json_decode($ap->getFollowers(), true); $test->assertEquals('OrderedCollection', $list['type']); $test->assertEquals([$remoteActor], $list['orderedItems']); }); $test->it('不正なフォローを拒否するはず', function($test) use ($make, $domain, $remoteActor): void { $ap = $make(); $test->assertEquals(400, $ap->handleInbox(['type' => 'Follow', 'actor' => $remoteActor, 'object' => 'https://1.1.1.1/someone'])[0]); $test->assertEquals(400, $ap->handleInbox(['type' => 'Follow', 'actor' => 'http://127.0.0.1/x', 'object' => "{$domain}/ap/actor"])[0]); // 受付ボックスが分からない $test->assertEquals(500, $ap->handleInbox(['type' => 'Follow', 'actor' => $remoteActor, 'object' => "{$domain}/ap/actor"])[0]); $test->assertEquals([], $ap->sent); }); $test->it('フォローの取り消しでフォロワーから外すはず', function($test) use ($make, $followers, $domain, $remoteActor): void { file_put_contents($followers, "{$remoteActor}\nhttps://1.1.1.1/users/bob\n"); $ap = $make(); // IDだけのUndo(Likeの取り消しかも知れない)と他人のフォローの取り消しは無視する $test->assertEquals(202, $ap->handleInbox(['type' => 'Undo', 'actor' => $remoteActor, 'object' => "{$remoteActor}#like"])[0]); $other = ['type' => 'Follow', 'actor' => 'https://1.1.1.1/users/bob', 'object' => "{$domain}/ap/actor"]; $test->assertEquals(202, $ap->handleInbox(['type' => 'Undo', 'actor' => $remoteActor, 'object' => $other])[0]); $test->assertEquals("{$remoteActor}\nhttps://1.1.1.1/users/bob\n", file_get_contents($followers)); $mine = ['type' => 'Follow', 'actor' => $remoteActor, 'object' => "{$domain}/ap/actor"]; $test->assertEquals(200, $ap->handleInbox(['type' => 'Undo', 'actor' => $remoteActor, 'object' => $mine])[0]); $test->assertEquals("https://1.1.1.1/users/bob\n", file_get_contents($followers)); }); $test->it('アカウントの削除でフォロワーから外すはず', function($test) use ($make, $followers): void { file_put_contents($followers, "https://1.1.1.1/users/bob\n"); $ap = $make(); $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]); $test->assertEquals("https://1.1.1.1/users/bob\n", file_get_contents($followers)); $test->assertEquals(202, $ap->handleInbox(['type' => 'Delete', 'actor' => 'https://1.1.1.1/users/bob', 'object' => 'https://1.1.1.1/users/bob'])[0]); $test->assertEquals('', file_get_contents($followers)); }); $test->it('未対応のアクティビティは202を返すはず', function($test) use ($make): void { // 5xxを返すと相手のサーバーが何日も再送する [$code] = $make()->handleInbox(['type' => 'Like', 'actor' => 'https://1.1.1.1/users/bob', 'object' => 'x']); $test->assertEquals(202, $code); }); $test->it('POSTとGETの署名を作れるはず', function($test) use ($make, $key): void { $ap = $make(); $parse = function(array $list): array { $h = []; foreach ($list as $line) { [$k, $v] = explode(': ', $line, 2); $h[$k] = $v; } return $h; }; $h = $parse($ap->sign('https://a.example/inbox', '{}')); $test->assertStringContains('headers="(request-target) host date digest"', $h['Signature']); $test->assertEquals('SHA-256='.base64_encode(hash('sha256', '{}', true)), $h['Digest']); $h = $parse($ap->sign('https://a.example/users/x?y=1', '', 'get')); $test->assertStringContains('headers="(request-target) host date"', $h['Signature']); $test->assertFalse(isset($h['Digest'])); preg_match('/signature="([^"]+)"/', $h['Signature'], $m); $signed = "(request-target): get /users/x?y=1\nhost: a.example\ndate: {$h['Date']}"; $test->assertTrue(Rsa::fromPem($key->publicPem())->verify($signed, base64_decode($m[1]))); }); $test->it('受付ボックスの署名を検証するはず', function($test) use ($make, $key, $domain, $remoteActor): void { $ap = $make(); $keyId = "{$domain}/ap/actor#main-key"; $actor = "{$domain}/ap/actor"; // 自分の鍵で署名したリクエストを「相手」として検証する $ap->remote[$actor] = ['id' => $actor, 'publicKey' => ['id' => $keyId, 'owner' => $actor, 'publicKeyPem' => $key->publicPem()]]; $body = json_encode(['type' => 'Follow', 'actor' => $actor, 'object' => 'x']); $headers = []; foreach ($ap->sign('https://'.parse_url($domain, PHP_URL_HOST).'/ap/inbox', $body) as $line) { [$k, $v] = explode(': ', $line, 2); $headers[$k] = $v; } $server = $_SERVER; $setup = function(array $override = []) use ($headers, $server): void { $_SERVER = $server; $_SERVER['REQUEST_METHOD'] = 'POST'; $_SERVER['REQUEST_URI'] = '/ap/inbox'; $_SERVER['HTTP_HOST'] = $headers['Host']; $_SERVER['HTTP_DATE'] = $headers['Date']; $_SERVER['HTTP_DIGEST'] = $headers['Digest']; $_SERVER['HTTP_SIGNATURE'] = $headers['Signature']; foreach ($override as $k => $v) $_SERVER[$k] = $v; }; try { $setup(); $test->assertTrue($ap->verifyInboxSignature($body, json_decode($body, true)), '正しい署名'); $setup(); $test->assertFalse($ap->verifyInboxSignature($body.' ', json_decode($body, true)), '本文の改竄'); $setup(['REQUEST_URI' => '/ap/other']); $test->assertFalse($ap->verifyInboxSignature($body, json_decode($body, true)), 'パスの改竄'); $setup(['HTTP_DATE' => gmdate('D, d M Y H:i:s \G\M\T', time() - 3600)]); $test->assertFalse($ap->verifyInboxSignature($body, json_decode($body, true)), '古い日付'); $setup(); $test->assertFalse($ap->verifyInboxSignature($body, ['type' => 'Follow', 'actor' => $remoteActor]), '署名者とactorが違う'); $setup(['HTTP_SIGNATURE' => str_replace('(request-target) ', '', $headers['Signature'])]); $test->assertFalse($ap->verifyInboxSignature($body, json_decode($body, true)), '(request-target)が無い'); } finally { $_SERVER = $server; } }); }); @unlink($followers); $test->printSummary();