true,
'verboseOutput' => true
]);
// 2つのサーバー(A・B)を1つのプロセスで動かす。公開IPのURLなので名前解決は要らない
$tmp = sys_get_temp_dir().'/lbfedi_'.bin2hex(random_bytes(6));
$inst = ['A' => 'https://1.1.1.1', 'B' => 'https://8.8.8.8'];
$use = function(string $n) use ($tmp, $inst): void {
Fedi::$baseUrl = $inst[$n];
Fedi::$dataDir = "{$tmp}/{$n}/ap";
Fedi::$userDir = "{$tmp}/{$n}/user/";
if (!is_dir(Fedi::$userDir)) mkdir(Fedi::$userDir, 0700, true);
};
$mkuser = function(string $n, string $name, int $id, int $role = 1) use ($use): void {
$use($n);
file_put_contents(Fedi::$userDir."{$id}.{$name}.json", json_encode([
'id' => $id, 'username' => $name, 'displayname' => ucfirst($name), 'bio' => "I am {$name}", 'avatar' => '',
'email' => "{$name}@example.com", 'regDate' => time(), 'gender' => -1, 'role' => $role, 'namecolor' => '',
]));
};
// 相手のサーバーのアクターをキャッシュに入れる(取得しなくても良い様に)
$seed = function(string $from, string $to, string $name) use ($use): void {
$use($to);
$doc = Actor::document(Fedi::localActor($name));
$use($from);
Remote::cacheActor($doc);
};
// 待ち行列を相手のサーバーの受付ボックスに届ける
$pump = function() use ($use, $inst): array {
$log = [];
for ($round = 0; $round < 5; $round++) {
$any = false;
foreach (array_keys($inst) as $n) {
$use($n);
foreach (glob(Fedi::path('queue/').'*.json') ?: [] as $f) {
$job = json_decode(file_get_contents($f), true);
unlink($f);
$any = true;
$dest = array_search('https://'.parse_url($job['inbox'], PHP_URL_HOST), $inst, true);
$use($dest);
[$code] = Inbox::handle($job['activity']);
$log[] = "{$job['activity']['type']}:{$code}";
$use($n);
}
}
if (!$any) break;
}
return $log;
};
$alice = 'https://1.1.1.1/ap/users/alice';
$carol = 'https://1.1.1.1/ap/users/carol';
$bob = 'https://8.8.8.8/ap/users/bob';
$test->describe('Fediverseの本文', function($test): void {
if (!ACTIVITYPUB_ENABLED) {
$test->skipAll('ACTIVITYPUB_ENABLEDが無効');
}
$test->it('メンションとハッシュタグを取り出すはず', function($test): void {
$t = "Hi @bob@b.example and @alice! #タグ #php https://x.example/#notatag @not@url";
$test->assertEquals(['@bob@b.example' => ['bob', 'b.example'], '@alice' => ['alice', null]], Html::mentions($t));
$test->assertEquals(['タグ', 'php'], Html::hashtags($t));
});
$test->it('プレーンテキストをHTMLにするはず', function($test): void {
Fedi::$baseUrl = 'https://1.1.1.1';
$html = Html::render("Hi @bob@b.example \nhttps://x.example/a?b=1, #tag\n\nnext", ['@bob@b.example' => 'https://8.8.8.8/ap/users/bob']);
$test->assertStringContains('@bob', $html);
$test->assertStringContains('<b>
', $html);
$test->assertStringContains('x.example/a?b=1,', $html);
$test->assertStringContains('#tag', $html);
$test->assertStringContains('
next
', $html);
// 解決出来なかったメンションはリンクにしない
$test->assertEquals('@nobody@x.example
', Html::render('@nobody@x.example'));
});
$test->it('Markdownを描画するはず', function($test): void {
Fedi::$baseUrl = 'https://1.1.1.1';
$r = fn(string $t) => Html::render($t);
$test->assertEquals('太字と斜体と線と<b>*x*
', $r('**太字**と*斜体*と~~線~~と`*x*`'));
$test->assertEquals('リンク
', $r('[**リンク**](https://x.example/a)'));
// javascript:等はリンクにしない
$test->assertEquals('[x](javascript:alert(1))
', $r('[x](javascript:alert(1))'));
$test->assertEquals('- one
- two
', $r("- a\n* *b*\n\n1. one\n2. two"));
$test->assertEquals('引用
強
本文
', $r("> 引用\n> **強**\n\n本文"));
$test->assertEquals("**そのまま** #tag @bob\n<script>
", $r("```php\n**そのまま** #tag @bob\n
日本語');
$test->assertFalse(str_contains($out, ''));
$test->assertNull(Remote::imageType(''));
});
$test->it('フォローしている人の投稿がホームに届くはず', function($test) use ($use, $pump): void {
$use('B');
$test->assertTrue(Social::post('bob', 'Hello #fedi')->isSuccess);
$test->assertEquals(['Create:202'], $pump());
$use('A');
[$home] = Timeline::query('home', 'alice');
$test->assertCount(1, $home);
$test->assertStringContains('Hello', $home[0]['object']['content']);
$test->assertCount(1, Timeline::query('federated', null)[0]);
$test->assertCount(0, Timeline::query('local', null)[0]);
$test->assertCount(1, Timeline::query('tag', null, ['tag' => 'fedi'])[0]);
});
$test->it('いいね・ブースト・返信が相手に届くはず', function($test) use ($use, $pump): void {
$use('A');
$id = Timeline::query('home', 'alice')[0][0]['object']['id'];
$test->assertTrue(Social::like('alice', $id)->isSuccess);
$test->assertTrue(Social::boost('alice', $id)->isSuccess);
$test->assertTrue(Social::post('alice', '@bob@b.example いいね!', ['inReplyTo' => $id])->isSuccess);
$pump();
$use('B');
$note = Notes::get(Fedi::localNoteId($id)[1]);
$test->assertCount(1, $note['likes']);
$test->assertCount(1, $note['boosts']);
$test->assertCount(1, $note['replies']);
$types = array_column(Actor::notifications('bob'), 'type');
foreach (['like', 'boost', 'reply'] as $t) $test->assertTrue(in_array($t, $types, true), "通知「{$t}」が無い");
// 返信は返信先のスレッドに出る
$test->assertCount(1, Timeline::replies($id));
});
$test->it('取り消しも届くはず', function($test) use ($use, $pump): void {
$use('A');
$id = Timeline::query('home', 'alice', ['limit' => 50])[0][count(Timeline::query('home', 'alice', ['limit' => 50])[0]) - 1]['object']['id'];
$test->assertTrue(Social::unlike('alice', $id)->isSuccess);
$test->assertTrue(Social::unboost('alice', $id)->isSuccess);
$pump();
$use('B');
$note = Notes::get(Fedi::localNoteId($id)[1]);
$test->assertCount(0, $note['likes']);
$test->assertCount(0, $note['boosts']);
});
$test->it('承認制のアカウントはリクエストを承認するまでフォローされないはず', function($test) use ($use, $pump, $alice, $carol): void {
$use('A');
Actor::saveSettings('alice', ['locked' => true]);
$test->assertTrue(Social::follow('carol', $alice)->isSuccess);
$test->assertEquals('pending', Actor::following('carol')[$alice]['state']);
$test->assertTrue(isset(Actor::requests('alice')[$carol]));
$test->assertTrue(Social::answerRequest('alice', $carol, true)->isSuccess);
$test->assertTrue(Actor::isFollowing('carol', $alice));
$test->assertTrue(Actor::isFollowedBy('alice', $carol));
Actor::saveSettings('alice', ['locked' => false]);
});
$test->it('フォロワー限定とダイレクトは関係ある人だけ見えるはず', function($test) use ($use, $pump): void {
$use('A');
$test->assertTrue(Social::post('alice', 'followers only', ['visibility' => 'followers'])->isSuccess);
$test->assertTrue(Social::post('alice', '@bob@b.example secret', ['visibility' => 'direct'])->isSuccess);
$pump();
$use('A');
$texts = fn($items) => array_map(fn($i) => Html::text($i['object']['content']), $items);
$carolHome = $texts(Timeline::query('home', 'carol')[0]);
$test->assertTrue(in_array('followers only', $carolHome, true), 'フォロワーに見えない');
$test->assertFalse(in_array('@bob secret', $carolHome, true), '関係無い人にダイレクトが見える');
$test->assertFalse(in_array('followers only', $texts(Timeline::query('local', null)[0]), true));
$test->assertFalse(in_array('followers only', $texts(Timeline::query('user', null, ['user' => 'alice'])[0]), true));
$use('B');
$bobHome = $texts(Timeline::query('home', 'bob')[0]);
$test->assertTrue(in_array('@bob secret', $bobHome, true), 'ダイレクトが届かない');
// bobはaliceのフォロワーではない
$test->assertFalse(in_array('followers only', $bobHome, true));
$test->assertTrue(in_array('mention', array_column(Actor::notifications('bob'), 'type'), true));
});
$test->it('編集と削除が届くはず', function($test) use ($use, $pump): void {
$use('B');
$own = Timeline::query('user', 'bob', ['user' => 'bob'])[0][0]['object']['id'];
$id = Fedi::localNoteId($own)[1];
$test->assertTrue(Social::edit('bob', $id, 'Edited #changed')->isSuccess);
$pump();
$use('A');
$test->assertEquals('Edited #changed', Html::text(Remote::stored($own)['content']));
$test->assertCount(1, Timeline::query('tag', null, ['tag' => 'changed'])[0]);
$test->assertCount(0, Timeline::query('tag', null, ['tag' => 'fedi'])[0]);
$use('B');
$test->assertTrue(Social::delete('bob', $id)->isSuccess);
$pump();
$use('A');
$test->assertNull(Remote::stored($own));
$test->assertFalse(in_array($own, array_map(fn($i) => $i['object']['id'], Timeline::query('federated', null)[0]), true));
});
$test->it('他人の投稿は編集・削除出来ないはず', function($test) use ($use): void {
$use('A');
$mine = Timeline::query('user', 'alice', ['user' => 'alice'])[0][0]['object']['id'];
$id = Fedi::localNoteId($mine)[1];
$test->assertFalse(Social::edit('carol', $id, 'x')->isSuccess);
$test->assertFalse(Social::delete('carol', $id)->isSuccess);
});
$test->it('偽装したアクティビティは受け付けないはず', function($test) use ($use, $alice, $bob): void {
$use('A');
// ローカルのアクターを名乗るリモートのアクティビティ
$test->assertEquals(403, Inbox::handle(['type' => 'Like', 'actor' => $alice, 'object' => 'x'])[0]);
// 他人の投稿として作られたCreate
$test->assertEquals(403, Inbox::handle(['type' => 'Create', 'actor' => $bob,
'object' => ['type' => 'Note', 'id' => 'https://8.8.8.8/n/1', 'attributedTo' => 'https://8.8.8.8/ap/users/eve', 'to' => [Fedi::PUBLIC]]])[0]);
// 違うサーバーのIDのNote
$test->assertEquals(403, Inbox::handle(['type' => 'Create', 'actor' => $bob,
'object' => ['type' => 'Note', 'id' => 'https://9.9.9.9/n/1', 'attributedTo' => $bob, 'to' => [Fedi::PUBLIC]]])[0]);
// 他人のフォローの取り消し
$test->assertEquals(202, Inbox::handle(['type' => 'Undo', 'actor' => $bob,
'object' => ['type' => 'Follow', 'actor' => 'https://8.8.8.8/ap/users/eve', 'object' => $alice]])[0]);
// 誰にも関係の無い投稿は保存しない
Inbox::handle(['type' => 'Create', 'actor' => 'https://8.8.8.8/ap/users/stranger',
'object' => ['type' => 'Note', 'id' => 'https://8.8.8.8/n/spam', 'attributedTo' => 'https://8.8.8.8/ap/users/stranger', 'to' => [Fedi::PUBLIC], 'content' => 'spam']]);
$test->assertNull(Remote::stored('https://8.8.8.8/n/spam'));
});
$test->it('ブロックするとフォロー関係が無くなり、投稿が見えなくなるはず', function($test) use ($use, $pump, $alice, $bob): void {
$use('B');
$test->assertTrue(Social::block('bob', $alice)->isSuccess);
$pump();
$use('A');
$test->assertFalse(Actor::isFollowing('alice', $bob));
$use('B');
$test->assertFalse(Actor::isFollowedBy('bob', $alice));
$test->assertTrue(Actor::isBlocked('bob', $alice));
// ブロックしている人からのフォローは拒否する
$use('A');
Social::follow('alice', $bob);
$pump();
$use('A');
$test->assertFalse(isset(Actor::following('alice')[$bob]));
$use('B');
$test->assertTrue(Social::unblock('bob', $alice)->isSuccess);
$pump();
});
$test->it('サイト全体でドメインをブロック出来るはず', function($test) use ($use, $bob): void {
$use('A');
$test->assertFalse(Social::blockInstance('1.1.1.1')->isSuccess);
$test->assertTrue(Social::blockInstance('evil.example')->isSuccess);
$test->assertTrue(Actor::isDomainBlocked('https://sub.evil.example/u/x'));
$test->assertFalse(Actor::isDomainBlocked('https://notevil.example/u/x'));
$test->assertEquals(403, Inbox::handle(['type' => 'Follow', 'actor' => 'https://evil.example/u/x', 'object' => 'x'])[0]);
// 待ち行列にも入れない
Delivery::enqueue('alice', ['type' => 'Note', 'id' => 'x'], ['https://evil.example/inbox']);
$test->assertEquals(0, Delivery::pending());
$test->assertTrue(Social::blockInstance('evil.example', false)->isSuccess);
$test->assertFalse(Actor::isDomainBlocked('https://evil.example/u/x'));
});
$test->it('通報を記録し、相手のサーバーにも送るはず', function($test) use ($use, $pump, $bob): void {
$use('A');
$test->assertTrue(Social::report('alice', $bob, [], '迷惑です')->isSuccess);
$test->assertEquals('迷惑です', Social::reports()[0]['content']);
$pump();
$use('B');
$test->assertTrue((bool)Social::reports()[0]['remote']);
$test->assertEquals('迷惑です', Social::reports()[0]['content']);
});
$test->it('アカウントの削除で全部から消えるはず', function($test) use ($use, $pump, $bob): void {
$use('A');
Social::follow('alice', $bob);
$pump();
$use('A');
$test->assertTrue(Actor::isFollowing('alice', $bob));
Inbox::handle(['type' => 'Delete', 'actor' => $bob, 'object' => $bob]);
$test->assertFalse(isset(Actor::following('alice')[$bob]));
$test->assertNull(Remote::cachedActor($bob));
});
$test->it('待ち行列は失敗したら間隔を空けて再試行するはず', function($test) use ($use): void {
$use('A');
foreach (glob(Fedi::path('queue/').'*.json') ?: [] as $f) unlink($f);
$failing = new class extends Delivery {
public static int $calls = 0;
public static bool $ok = false;
protected static function send(string $from, string $inbox, array $activity): bool {
self::$calls++;
return self::$ok;
}
protected static function runBackfill(int $budget): int {
return 0;
}
};
$failing::enqueue('alice', ['type' => 'Like', 'id' => 'x'], ['https://9.9.9.9/inbox']);
$res = $failing::run();
$test->assertEquals(['sent' => 0, 'failed' => 1, 'dropped' => 0, 'backfilled' => 0], $res->data);
// 次の時刻までは送らない
$failing::run();
$test->assertEquals(1, $failing::$calls);
$job = glob(Fedi::path('queue/').'*.json')[0];
$data = Fedi::read($job);
$data['next'] = time() - 1;
Fedi::write($job, $data);
$failing::$ok = true;
$test->assertEquals(['sent' => 1, 'failed' => 0, 'dropped' => 0, 'backfilled' => 0], $failing::run()->data);
$test->assertEquals(0, Delivery::pending());
});
$test->it('古いリモートの投稿を消すはず', function($test) use ($use): void {
$use('A');
$old = ['id' => 'https://8.8.8.8/n/old', 'type' => 'Note', 'attributedTo' => 'https://8.8.8.8/ap/users/x',
'to' => [Fedi::PUBLIC], 'content' => 'old', 'published' => Fedi::now(time() - 200 * 86400)];
Remote::store($old);
Timeline::add($old);
$res = Timeline::cleanup(90);
$test->assertTrue($res->data['removed'] >= 1);
$test->assertNull(Remote::stored($old['id']));
// ローカルの投稿は消さない
$test->assertTrue(count(Timeline::query('user', 'alice', ['user' => 'alice'])[0]) > 0);
});
$test->it('GETの署名を検証出来るはず', function($test) use ($use, $alice): void {
$use('A');
$key = Actor::privateKey('alice');
$test->assertNotNull($key);
$ap = new class extends Activitypub {
public array $docs = [];
public function fetchRemoteJson(string $url): ?array { return $this->docs[$url] ?? null; }
};
$ap->docs[$alice] = Actor::document(Fedi::localActor('alice'));
$headers = [];
foreach ($ap->signRequest('https://8.8.8.8/ap/users/bob/notes/x', '', $key, 'get', Actor::keyId('alice')) as $line) {
[$k, $v] = explode(': ', $line, 2);
$headers[$k] = $v;
}
$server = $_SERVER;
try {
$_SERVER['REQUEST_METHOD'] = 'GET';
$_SERVER['REQUEST_URI'] = '/ap/users/bob/notes/x';
$_SERVER['HTTP_HOST'] = $headers['Host'];
$_SERVER['HTTP_DATE'] = $headers['Date'];
$_SERVER['HTTP_SIGNATURE'] = $headers['Signature'];
$test->assertEquals($alice, $ap->signer());
$_SERVER['REQUEST_URI'] = '/ap/users/bob/notes/y';
$test->assertNull($ap->signer());
} finally {
$_SERVER = $server;
}
});
});
// 後片付け
Fedi::$baseUrl = Fedi::$dataDir = Fedi::$userDir = null;
$rm = function(string $dir) use (&$rm): void {
foreach (scandir($dir) ?: [] as $f) {
if ($f === '.' || $f === '..') continue;
is_dir("{$dir}/{$f}") ? $rm("{$dir}/{$f}") : @unlink("{$dir}/{$f}");
}
@rmdir($dir);
};
if (is_dir($tmp)) $rm($tmp);
$test->printSummary();