true, 'verboseOutput' => true ]); // 本物のデータを触らない様に、一時ディレクトリを使う $base = sys_get_temp_dir().'/lbgitler_'.bin2hex(random_bytes(6)); foreach (['repos', 'user', 'keys'] as $d) mkdir("{$base}/{$d}", 0700, true); $rmTree = function(string $dir) use (&$rmTree): void { foreach (@scandir($dir) ?: [] as $f) { if ($f === '.' || $f === '..') continue; $p = "{$dir}/{$f}"; if (is_dir($p) && !is_link($p)) $rmTree($p); else { @chmod($p, 0666); @unlink($p); } } @rmdir($dir); }; register_shutdown_function(fn() => $rmTree($base)); Gitler::$root = "{$base}/repos"; Gitler::$userDir = "{$base}/user"; Keys::$dir = "{$base}/keys"; // ユーザー(role、fediDefaultはロールを管理者が変えた印) $mkUser = function(int $id, string $name, int $role, bool $rolesSet = true) use ($base): \stdClass { $u = ['id' => $id, 'username' => $name, 'email' => "{$name}@example.com", 'displayname' => '', 'role' => $role, 'password' => 'x', 'tokens' => []]; if ($rolesSet) $u['fediDefault'] = true; file_put_contents("{$base}/user/{$id}.{$name}.json", json_encode($u)); return Gitler::user($name); }; $admin = $mkUser(1, 'admin001', Roles::MEMBER | Roles::ADMIN); $owner = $mkUser(2, 'owner001', Roles::MEMBER | Roles::GIT_REPOS); $other = $mkUser(3, 'other001', Roles::MEMBER, false); $plain = $mkUser(4, 'plain001', Roles::MEMBER); $banned = $mkUser(5, 'banned01', Roles::BANNED); $pulls = $mkUser(6, 'pulls001', Roles::MEMBER | Roles::GIT_PULLS | Roles::GIT_ISSUES); // 公開鍵(ed25519、ecdsa、RSA) $str = fn(string $s) => pack('N', strlen($s)).$s; $ed = fn() => 'ssh-ed25519 '.base64_encode($str('ssh-ed25519').$str(random_bytes(32))); $rsa = function(int $bits) use ($str): string { $n = chr(0xc0).random_bytes(intdiv($bits, 8) - 1); return 'ssh-rsa '.base64_encode($str('ssh-rsa').$str("\x01\x00\x01").$str("\0".$n)); }; $test->describe('Gitler(権限)', function($test) use ($admin, $owner, $other, $plain, $banned, $pulls): void { $test->it('ロールで使える機能が決まるはず', function($test) use ($admin, $owner, $other, $plain, $banned, $pulls): void { $can = fn(?\stdClass $u) => [Gitler::userCan($u, 'repos'), Gitler::userCan($u, 'issues'), Gitler::userCan($u, 'pulls')]; $test->assertSame([true, true, true], $can($admin)); $test->assertSame([true, false, false], $can($owner)); $test->assertSame([false, true, false], $can($other), 'ロールを変えていないユーザーはイシューだけ(既定)'); $test->assertSame([false, false, false], $can($plain)); $test->assertSame([false, false, false], $can($banned)); $test->assertSame([false, true, true], $can($pulls)); $test->assertSame([false, false, false], $can(null)); }); $test->it('リポジトリの名前とディレクトリを確かめるはず', function($test): void { foreach (['repo', 'my-repo_1.0', 'A', '_x'] as $ok) $test->assertTrue(Gitler::validName($ok), $ok); foreach (['', '.', '..', 'a..b', 'x.git', 'X.GIT', '-x', '.x', 'a/b', 'a b', str_repeat('a', 101), "a\0"] as $bad) $test->assertFalse(Gitler::validName($bad), $bad); $test->assertFalse(Gitler::validOwner('a-b')); Gitler::$root = null; $root = str_replace('\\', '/', ROOT); $test->assertSame(dirname($root).'/git', Gitler::root(), '既定は../git(ROOTから)'); $test->assertSame(dirname($root, 3).'/git', Gitler::resolvePath('../../../git')); $test->assertSame('/var/www/git', Gitler::resolvePath('/var/www/htdocs/example.com/www/../../../git')); $test->assertSame('/git', Gitler::resolvePath('/htdocs/example.com/www/../../../git'), 'chrootの中'); $test->assertSame('/a/b', Gitler::resolvePath('/a/./b/')); }); }); $test->describe('Gitler(リポジトリ)', function($test) use ($base, $admin, $owner, $other, $plain, $banned): void { Gitler::$root = "{$base}/repos"; $test->it('ロールが有ればリポジトリを作れるはず', function($test) use ($owner, $plain): void { $test->assertFalse(Gitler::create($plain, 'nope')->isSuccess); $res = Gitler::create($owner, 'public1', ['description' => '公開', 'readme' => true]); $test->assertTrue($res->isSuccess, (string)$res->message); $test->assertTrue(Gitler::exists('owner001', 'public1')); $repo = Gitler::open('owner001', 'public1'); $test->assertSame("# public1\n\n公開\n", $repo->blob($repo->resolve('master:README.md'))); $test->assertSame('owner001', $repo->commit($repo->resolve('master'))->author->name); $test->assertSame('group', $repo->config->get('core.sharedrepository')); $test->assertSame("公開\n", file_get_contents($repo->path.'/description')); $test->assertFalse(Gitler::create($owner, 'public1')->isSuccess, '同じ名前'); $test->assertFalse(Gitler::create($owner, 'bad.git')->isSuccess); $res = Gitler::create($owner, 'secret', ['private' => true, 'objectFormat' => 'sha256', 'branch' => 'trunk']); $test->assertTrue($res->isSuccess, (string)$res->message); $repo = Gitler::open('owner001', 'secret'); $test->assertSame('sha256', $repo->hashAlgo); $test->assertSame('refs/heads/trunk', $repo->headRef()); $test->assertSame('refs/heads/master', Gitler::open('owner001', 'public1')->headRef(), '既定のブランチはmaster'); $test->assertTrue($repo->isEmpty()); $names = array_map(fn($r) => $r['name'], Gitler::list('owner001')); sort($names); $test->assertSame(['public1', 'secret'], $names); }); $test->it('公開・非公開と共同作業者で権限が決まるはず', function($test) use ($admin, $owner, $other, $plain, $banned): void { $a = fn(?\stdClass $u, string $name) => Gitler::access($u, 'owner001', $name); $test->assertSame(Gitler::READ, $a(null, 'public1')); $test->assertSame(Gitler::NONE, $a(null, 'secret')); $test->assertSame(Gitler::ADMIN, $a($owner, 'secret')); $test->assertSame(Gitler::ADMIN, $a($admin, 'secret')); $test->assertSame(Gitler::NONE, $a($other, 'secret')); $test->assertSame(Gitler::READ, $a($other, 'public1')); $test->assertSame(Gitler::NONE, $a($banned, 'secret')); $test->assertSame(Gitler::NONE, $a($owner, 'nothing')); $meta = Gitler::meta('owner001', 'secret'); $meta['collaborators'] = ['other001' => 'read', 'plain001' => 'write', 'banned01' => 'write']; Gitler::saveMeta('owner001', 'secret', $meta); $test->assertSame(Gitler::READ, $a($other, 'secret')); $test->assertSame(Gitler::WRITE, $a($plain, 'secret'), '書ける共同作業者はロールが無くても良い'); $test->assertSame(Gitler::NONE, $a($banned, 'secret'), 'BANされたユーザーは共同作業者でも駄目'); // 所有者がロールを失うと読むだけ $lost = clone $owner; $lost->role = Roles::MEMBER; $test->assertSame(Gitler::READ, $a($lost, 'secret')); }); $test->it('名前を変えて、消せるはず', function($test) use ($owner): void { $test->assertTrue(Gitler::create($owner, 'tmp1')->isSuccess); $test->assertFalse(Gitler::rename('owner001', 'tmp1', 'public1')->isSuccess, '有る名前'); $test->assertFalse(Gitler::rename('owner001', 'tmp1', '../x')->isSuccess); $test->assertTrue(Gitler::rename('owner001', 'tmp1', 'tmp2')->isSuccess); $test->assertFalse(Gitler::exists('owner001', 'tmp1')); $test->assertTrue(Gitler::delete('owner001', 'tmp2')); $test->assertFalse(Gitler::exists('owner001', 'tmp2')); $test->assertFalse(is_dir(Gitler::root().'/owner001/tmp2.git')); }); $test->it('全部のリポジトリを整理出来るはず', function($test): void { $res = Gitler::gcAll(); $test->assertTrue($res->isSuccess, (string)$res->message); $repo = Gitler::open('owner001', 'public1'); $test->assertCount(1, $repo->objects->packs(true)); $test->assertStringContains('公開', $repo->blob($repo->resolve('master:README.md'))); }); }); $test->describe('Gitler(SSHの鍵とトークン)', function($test) use ($ed, $rsa, $str): void { $test->it('公開鍵を解析して指紋を出すはず', function($test) use ($ed, $rsa, $str): void { $k = Keys::parse($ed().' me@pc'); $test->assertSame('ssh-ed25519', $k['type']); $test->assertSame('me@pc', $k['comment']); $test->assertSame('SHA256:'.rtrim(base64_encode(hash('sha256', base64_decode($k['key']), true)), '='), $k['fingerprint']); $test->assertSame(4096, Keys::parse($rsa(4096))['bits']); $test->assertNull(Keys::parse($rsa(1024)), '短いRSAの鍵'); $test->assertNotNull(Keys::parse('ecdsa-sha2-nistp256 '.base64_encode($str('ecdsa-sha2-nistp256').$str('nistp256').$str("\x04".random_bytes(64))))); // 種類が中身と違う、壊れた、未対応 $test->assertNull(Keys::parse('ssh-rsa '.substr($ed(), 12))); $test->assertNull(Keys::parse('ssh-ed25519 '.base64_encode($str('ssh-ed25519').$str('short')))); $test->assertNull(Keys::parse('ssh-dss AAAAB3NzaC1kc3MAAACBAP')); $test->assertNull(Keys::parse('from="x" ssh-ed25519 AAAA')); $test->assertNull(Keys::parse('')); }); $test->it('鍵を追加・検索・削除出来て、同じ鍵は2回登録出来ないはず', function($test) use ($ed): void { $key = $ed(); $res = Keys::addKey('owner001', $key.' laptop', ''); $test->assertTrue($res->isSuccess, (string)$res->message); $fp = $res->data['fingerprint']; $test->assertSame('laptop', Keys::load('owner001')['keys'][0]['title']); $test->assertFalse(Keys::addKey('other001', $key, 'x')->isSuccess, '他のユーザーでも同じ鍵は駄目'); [$type, $b64] = explode(' ', $key); [$user, $found] = Keys::find($type, $b64); $test->assertSame('owner001', $user); $test->assertSame($fp, $found['fingerprint']); $line = Keys::authorizedKey($user, $found); $test->assertStringContains('restrict,command="', $line); $test->assertStringContains('/gitler.php serve owner001 '.$fp.'"', $line); $test->assertTrue(str_ends_with($line, " {$type} {$b64}")); $test->assertSame(['owner001'], array_keys(Keys::all())); $test->assertTrue(Keys::removeKey('owner001', $fp)); $test->assertNull(Keys::find($type, $b64)); $test->assertFalse(Keys::removeKey('owner001', $fp)); }); $test->it('アクセストークンを作って確かめられるはず', function($test): void { $rw = Keys::addToken('owner001', 'ノートPC', true); $ro = Keys::addToken('owner001', '読むだけ', false); $test->assertTrue($rw->isSuccess && $ro->isSuccess); $test->assertTrue(str_starts_with($rw->data['token'], 'lbg_')); $test->assertTrue(Keys::checkToken('owner001', $rw->data['token'])['write']); $test->assertFalse(Keys::checkToken('owner001', $ro->data['token'])['write']); $test->assertNull(Keys::checkToken('other001', $rw->data['token']), '他のユーザーのトークン'); $test->assertNull(Keys::checkToken('owner001', 'lbg_'.str_repeat('0', 40))); $test->assertFalse(str_contains((string)json_encode(Keys::load('owner001')), $rw->data['token']), '平文は保存しない'); $test->assertTrue(Keys::removeToken('owner001', $rw->data['id'])); $test->assertNull(Keys::checkToken('owner001', $rw->data['token'])); $test->assertFalse(Keys::addToken('owner001', '', true)->isSuccess); }); }); $test->describe('Gitler(アーカイブ)', function($test) use ($base): void { $repo = Repository::init("{$base}/archive.git"); $me = new Person('諏訪子', 's@x', 1700000000, '+0900'); $long = str_repeat('長いディレクトリ/', 12).'ファイル.txt'; $files = ['README.md' => "# テスト\n", 'bin/run.sh' => "#!/bin/sh\necho hi\n", 'big.bin' => random_bytes(70000), $long => "長い\n", 'empty.txt' => '']; $changes = []; foreach ($files as $p => $data) $changes[$p] = [$p === 'bin/run.sh' ? Tree::EXEC : Tree::FILE, $data]; $changes['link'] = [Tree::LINK, 'README.md']; $commit = $repo->writeCommit(Commit::create($repo->updateTree(null, $changes), [], $me, $me, "x\n")); $repo->refs->update('refs/heads/main', $commit); $test->it('tarに全部のファイルを入れるはず(長いパス、実行、リンク、gzip)', function($test) use ($repo, $commit, $files, $long): void { foreach (['tar', 'tar.gz'] as $fmt) { $s = ''; Archive::write($repo, $commit, $fmt, 'test-1/', function($b) use (&$s): void { $s .= $b; }); if ($fmt === 'tar.gz') $s = (string)gzdecode($s); // tarを読む $got = []; $modes = []; $links = []; $pax = null; for ($pos = 0; $pos + 512 <= strlen($s);) { $h = substr($s, $pos, 512); if (trim($h, "\0") === '') break; $sum = array_sum(array_map('ord', str_split(substr($h, 0, 148).' '.substr($h, 156)))); $test->assertSame($sum, octdec(trim(substr($h, 148, 8), "\0 ")), 'チェックサム'); $name = rtrim(substr($h, 0, 100), "\0"); $prefix = rtrim(substr($h, 345, 155), "\0"); $size = octdec(trim(substr($h, 124, 12), "\0 ")); $type = $h[156]; $data = substr($s, $pos + 512, $size); $pos += 512 + (int)ceil($size / 512) * 512; if ($type === 'x') { preg_match('/ path=([^\n]*)\n/', $data, $m); $pax = $m[1]; continue; } $path = $pax ?? ($prefix !== '' ? $prefix.'/'.$name : $name); $pax = null; $modes[$path] = octdec(trim(substr($h, 100, 8), "\0 ")); if ($type === '2') $links[$path] = rtrim(substr($h, 157, 100), "\0"); else if ($type === '0') $got[$path] = $data; } foreach ($files as $p => $data) $test->assertSame(bin2hex($data), bin2hex($got['test-1/'.$p] ?? 'なし'), "{$fmt}: {$p}"); $test->assertSame(0775, $modes['test-1/bin/run.sh']); $test->assertSame(0664, $modes['test-1/README.md']); $test->assertSame(['test-1/link' => 'README.md'], $links); $test->assertTrue(isset($modes['test-1/bin/']), 'ディレクトリ'); $test->assertTrue(strlen('test-1/'.$long) > 155); } }); $test->it('zipに全部のファイルを入れるはず', function($test) use ($repo, $commit, $files): void { $s = ''; Archive::write($repo, 'HEAD', 'zip', 'test-1/', function($b) use (&$s): void { $s .= $b; }); // 中央ディレクトリから読む $end = strrpos($s, "PK\x05\x06"); $e = unpack('Vsig/vdisk/vcdisk/vcount/vtotal/Vsize/Voffset', $s, $end); $got = []; $p = $e['offset']; for ($i = 0; $i < $e['total']; $i++) { $c = unpack('Vsig/vmade/vneed/vflag/vmethod/vtime/vdate/Vcrc/Vcsize/Vusize/vnlen/velen/vclen/vdisk/vint/Vext/Vlocal', $s, $p); $name = substr($s, $p + 46, $c['nlen']); $p += 46 + $c['nlen'] + $c['elen'] + $c['clen']; $l = unpack('Vsig/vneed/vflag/vmethod/vtime/vdate/Vcrc/Vcsize/Vusize/vnlen/velen', $s, $c['local']); $raw = substr($s, $c['local'] + 30 + $l['nlen'] + $l['elen'], $c['csize']); $data = $c['method'] === 8 ? (string)gzinflate($raw) : $raw; $test->assertSame($c['crc'], crc32($data), $name); $test->assertSame(0x0800, $c['flag'] & 0x0800, 'UTF-8'); $got[$name] = [$data, $c['ext'] >> 16]; } foreach ($files as $path => $data) $test->assertSame(bin2hex($data), bin2hex($got['test-1/'.$path][0] ?? 'なし'), $path); $test->assertSame(0100755, $got['test-1/bin/run.sh'][1]); $test->assertSame(0120777, $got['test-1/link'][1]); $test->assertSame('README.md', $got['test-1/link'][0]); if (class_exists('ZipArchive')) { file_put_contents(sys_get_temp_dir().'/lbgitler-test.zip', $s); $z = new \ZipArchive(); $test->assertTrue($z->open(sys_get_temp_dir().'/lbgitler-test.zip', \ZipArchive::CHECKCONS) === true); $z->close(); @unlink(sys_get_temp_dir().'/lbgitler-test.zip'); } }); }); $test->describe('Gitler(言語の割合)', function($test) use ($base): void { $repo = Repository::init("{$base}/langs.git"); $me = new Person('諏訪子', 's@x', 1700000000, '+0900'); $files = [ 'src/main.c' => str_repeat('c', 600), 'src/util.h' => "int f(void);\n".str_repeat('h', 87), 'src/app.hpp' => "class A {};\n", 'lib/x.h' => "namespace x {}\n".str_repeat('p', 86), 'index.php' => str_repeat('p', 300), 'style.css' => str_repeat('s', 100), 'view/a.maron' => str_repeat('m', 50), 'build' => "#!/usr/bin/env -S php-8.5\n".str_repeat('p', 74), 'run' => "#!/bin/sh\necho\n", 'Makefile' => "all:\n", 'README.md' => str_repeat('r', 5000), 'data.json' => str_repeat('j', 5000), 'logo.png' => "\x89PNG\0\0".random_bytes(100), 'vendor/lib.js' => str_repeat('v', 5000), 'node_modules/a/b.js' => str_repeat('v', 5000), 'docs/x.py' => str_repeat('d', 5000), 'app.min.js' => str_repeat('v', 5000), 'gen/x.pb.go' => str_repeat('g', 5000), 'empty' => "", 'third/keep.js' => str_repeat('k', 40), 'tools/x.txt' => str_repeat('t', 20), 'shaders/a.fs' => "#version 450\nvoid main() {}\n", ]; $changes = []; foreach ($files as $p => $data) $changes[$p] = [in_array($p, ['build', 'run'], true) ? Tree::EXEC : Tree::FILE, $data]; $changes['link.c'] = [Tree::LINK, 'src/main.c']; $tree = $repo->updateTree(null, $changes); $commit = $repo->writeCommit(Commit::create($tree, [], $me, $me, "x\n")); $test->it('拡張子・名前・#!・中身で言語を決め、外部のコードとドキュメントとデータは数えないはず', function($test) use ($repo, $commit): void { $s = Languages::stats($repo, $commit); $test->assertSame(['C' => 700, 'PHP' => 400, 'C++' => 113, 'CSS' => 100, 'Maron' => 50, 'JavaScript' => 40, 'GLSL' => 28, 'Shell' => 15, 'Makefile' => 5], $s); }); $test->it('.gitattributesで変えられるはず', function($test) use ($repo, $changes, $me): void { $changes['.gitattributes'] = [Tree::FILE, "*.json linguist-detectable\nvendor/** -linguist-vendored\n/src/*.h linguist-language=cpp\n" ."index.php linguist-generated=true\nthird/** linguist-vendored\n*.txt linguist-language=Python\nview/[a-z].maron linguist-documentation\n"]; $commit = $repo->writeCommit(Commit::create($repo->updateTree(null, $changes), [], $me, $me, "y\n")); $s = Languages::stats($repo, $commit); $test->assertSame(['JSON' => 5000, 'JavaScript' => 5000, 'C' => 600, 'C++' => 213, 'PHP' => 100, 'CSS' => 100, 'GLSL' => 28, 'Python' => 20, 'Shell' => 15, 'Makefile' => 5], $s); }); $test->it('パターンを正規表現にするはず', function($test): void { $m = fn(string $pat, string $path): bool => (bool)preg_match(Languages::glob($pat), $path); $test->assertTrue($m('*.c', 'a/b/x.c')); $test->assertFalse($m('/*.c', 'a/x.c')); $test->assertTrue($m('/*.c', 'x.c')); $test->assertTrue($m('a/**/x.c', 'a/x.c')); $test->assertTrue($m('a/**/x.c', 'a/b/c/x.c')); $test->assertTrue($m('**/x.c', 'q/x.c')); $test->assertTrue($m('a/**', 'a/b/c')); $test->assertFalse($m('a/*', 'a/b/c')); $test->assertTrue($m('[!a]?.c', 'bb.c')); $test->assertFalse($m('[!a]?.c', 'ab.c')); $test->assertFalse($m('a/', 'a/b')); }); $test->it('保存して、一覧には既定のブランチの一番多い言語を出すはず', function($test) use ($repo, $commit, $tree): void { $test->assertNull(Languages::primary($repo->path)); $s = Languages::get($repo, $commit, true); $test->assertSame('C', array_key_first($s)); $c = json_decode((string)file_get_contents($repo->path.'/gitler-languages.json'), true); $test->assertSame($tree, $c['head']); $test->assertSame($s, $c['trees'][$tree]); $test->assertSame(['name' => 'C', 'color' => '#555555'], Languages::primary($repo->path)); $p = Languages::present($s); $test->assertSame('C', $p[0]['name']); $test->assertSame('48.2%', $p[0]['percent']); $p = Languages::present(['C' => 2000, 'D' => 1]); $test->assertSame(['99.950', '0.050'], array_column($p, 'width')); $test->assertSame(['100.0%', '<0.1%'], array_column($p, 'percent')); $many = []; foreach (['C', 'Go', 'Zig', 'PHP', 'Odin', 'Hare', 'Rust', 'Lua', 'Nim', 'Shell'] as $i => $l) $many[$l] = 100 - $i; $p = Languages::present($many); $test->assertSame(8, count($p)); $test->assertSame('その他', $p[7]['name']); $test->assertSame([], Languages::present([])); }); }); $test->describe('Gitler(イシュー)', function($test) use ($owner): void { Gitler::create($owner, 'issues1'); $db = Issues::open('owner001', 'issues1'); $me = ['user' => 'owner001']; $test->it('イシューとコメントを作り、探せるはず', function($test) use ($db, $me): void { $test->assertFalse($db->create($me, " \n", 'x')->isSuccess, '題名が無い'); $r1 = $db->create($me, "落ちる\nバグ", "手順:\n1. 開く"); $test->assertTrue($r1->isSuccess); $test->assertSame(1, $r1->data['number']); $test->assertSame('落ちる バグ', $r1->data['issue']['title'], '改行は空白'); $test->assertSame(2, $db->create(['user' => 'other001'], '機能の要望', '')->data['number']); $c = $db->comment(1, ['user' => 'other001'], '再現しました'); $test->assertTrue($c->isSuccess); $test->assertSame(1, $c->data['comment']['id']); $test->assertFalse($db->comment(1, $me, ' ')->isSuccess); $test->assertFalse($db->comment(99, $me, 'x')->isSuccess); $test->assertSame(['open' => 2, 'closed' => 0], $db->counts()); $test->assertSame([2, 1], array_column($db->search(), 'number')); $test->assertSame([1], array_column($db->search(['q' => '落ちる']), 'number')); $test->assertSame([1], array_column($db->search(['q' => '#1']), 'number')); $test->assertSame([2], array_column($db->search(['author' => 'other001']), 'number')); $test->assertSame([1, 2], array_column($db->search(['sort' => 'comments']), 'number')); $test->assertSame(1, $db->index()['issues'][1]['comments']); }); $test->it('編集・閉じる・ラベル・担当者・マイルストーンが記録されるはず', function($test) use ($db, $me): void { $test->assertTrue($db->edit(1, $me, '起動すると落ちる', '新しい本文')->isSuccess); $bug = $db->saveLabel(null, 'バグ', '#ee4030', '壊れている')->data['id']; $test->assertFalse($db->saveLabel(null, 'ばぐ', 'red', '')->isSuccess, '色'); $test->assertFalse($db->saveLabel(null, 'バグ', '#000000', '')->isSuccess, '同じ名前'); $ms = $db->saveMilestone(null, 'v1.0', '最初', '2026-12-31')->data['id']; $test->assertFalse($db->saveMilestone(null, 'x', '', '2026-02-30')->isSuccess, '不正な日付'); $db->triage(1, $me, [$bug, 999], ['owner001'], $ms); $i = $db->get(1); $test->assertSame([$bug], $i['labels'], '無いラベルは付かない'); $test->assertSame(['owner001'], $i['assignees']); $test->assertSame($ms, $i['milestone']); $test->assertSame(['title', 'labels', 'assignees', 'milestone'], array_column($i['events'], 'type')); $test->assertNotNull($db->setState(1, false, $me)); $test->assertNull($db->setState(1, false, $me), '閉じたイシューは閉じない'); $test->assertSame(['open' => 1, 'closed' => 1], $db->counts()); $test->assertSame([1], array_column($db->search(['state' => 'closed', 'label' => $bug, 'milestone' => $ms, 'assignee' => 'owner001']), 'number')); $db->deleteLabel($bug); $db->deleteMilestone($ms); $i = $db->get(1); $test->assertSame([], $i['labels']); $test->assertNull($i['milestone']); $test->assertSame('新しい本文', $i['body']); $test->assertSame('再現しました(編集)', $db->editComment(1, 1, '再現しました(編集)')['body']); $test->assertNotNull($db->editComment(1, 1, null)); $test->assertSame([], $db->get(1)['comments']); $test->assertTrue($db->delete(2)); $test->assertNull($db->get(2)); $test->assertSame(3, $db->create($me, '次', '')->data['number'], '番号は使い回さない'); }); $test->it('プッシュしたコミットでイシューに言及し、既定のブランチの「fixes #N」で閉じるはず', function($test) use ($db, $me): void { $repo = $db->repo; $db->setState(1, true, $me); $p = new Person('諏訪子', 's@x', 1700000000, '+0900'); $c1 = $repo->writeCommit(Commit::create($repo->updateTree(null, ['a' => [Tree::FILE, "1\n"]]), [], $p, $p, "作業中 #3\n")); $repo->refs->update('refs/heads/topic', $c1); $test->assertSame([], $db->processPush([['refs/heads/topic', $repo->zeroId(), $c1]], 'refs/heads/master')); $test->assertSame('open', $db->get(3)['state'], '他のブランチでは閉じない'); $c2 = $repo->writeCommit(Commit::create($repo->updateTree(null, ['a' => [Tree::FILE, "2\n"]]), [$c1], $p, $p, "直した\n\nFixes #1, closes #3 (see #99)\n")); $repo->refs->update('refs/heads/master', $c2); $test->assertSame([1, 3], $db->processPush([['refs/heads/master', $repo->zeroId(), $c2]], 'refs/heads/master')); $i = $db->get(3); $test->assertSame('closed', $i['state']); $types = array_column($i['events'], 'type'); $test->assertSame(['commit', 'commit', 'closed'], $types); $test->assertSame($c2, end($i['events'])['commit']); $test->assertSame([], $db->processPush([['refs/heads/master', $c1, $c2]], 'refs/heads/master'), '2回目は何もしない'); }); $test->it('Markdownを安全にして「#3」とURLをリンクにするはず', function($test): void { $h = Issues::render("[x](javascript:alert(1)) #3 https://example.com/a?b=1&c=2\n\n`#4` [#5](https://x.example)", '/gitler/o/r/issues'); $test->assertFalse(str_contains($h, 'javascript:')); $test->assertFalse(str_contains($h, 'assertTrue(str_contains($h, '#3')); $test->assertTrue(str_contains($h, 'assertTrue(str_contains($h, '#4'), 'コードの中はそのまま'); $test->assertFalse(str_contains($h, 'issues/5'), 'リンクの中はそのまま'); $h = Issues::render('@owner001 と @nobody と a@owner001.example', '/x'); $test->assertTrue(str_contains($h, '@owner001')); $test->assertFalse(str_contains($h, '/gitler/nobody'), '居ないユーザー'); $test->assertFalse(str_contains($h, 'gitler/owner001.example')); }); }); $test->describe('Gitler(Fediverse)', function($test) use ($base, $owner): void { if (!Fedi::enabled()) { $test->skipAll('マルチユーザーのFediverse(ACTIVITYPUB_MULTIUSER)が無効'); return; } Fedi::$dataDir = "{$base}/ap"; Fedi::$baseUrl = 'https://lb.test'; Gitler::create($owner, 'fedi1', ['description' => 'テスト']); Gitler::create($owner, 'hidden', ['private' => true]); $meta = Gitler::meta('owner001', 'fedi1'); $repoUrl = 'https://lb.test/gitler/owner001/fedi1'; // リモートのアクター(取得しない様にキャッシュに入れる) $alice = 'https://8.8.8.8/users/alice'; $bob = 'https://9.9.9.9/users/bob'; foreach ([$alice, $bob] as $a) { Remote::cacheActor(['id' => $a, 'type' => 'Person', 'preferredUsername' => basename($a), 'name' => ucfirst(basename($a)), 'inbox' => "{$a}/inbox", 'endpoints' => ['sharedInbox' => dirname($a, 2).'/inbox'], 'url' => dirname($a, 2).'/@'.basename($a)]); } $queue = function(): array { $out = []; foreach (glob(Fedi::path('queue/').'*.json') ?: [] as $f) { $j = json_decode((string)file_get_contents($f), true); $out[] = [$j['from'], $j['inbox'], $j['activity']['type'], $j['activity']]; unlink($f); } return $out; }; $test->it('WebFingerとアクターを返すはず(非公開は無し)', function($test) use ($meta, $repoUrl): void { $test->assertSame('acct:owner001.fedi1@lb.test', Federation::webfinger('acct:owner001.fedi1@lb.test')['subject']); $test->assertSame($repoUrl, Federation::webfinger('acct:OWNER001.FEDI1@LB.TEST')['links'][0]['href'], '大文字・小文字'); $test->assertSame('acct:owner001.fedi1@lb.test', Federation::webfinger($repoUrl)['subject']); $test->assertNull(Federation::webfinger('acct:owner001.hidden@lb.test')); $test->assertNull(Federation::webfinger('acct:owner001.fedi1@other.example')); $doc = Federation::document('owner001', 'fedi1', $meta, 'https://lb.test/gitler/owner001/fedi1.git'); $test->assertSame('Service', $doc['type']); $test->assertSame('owner001.fedi1', $doc['preferredUsername']); $test->assertSame("{$repoUrl}/inbox", $doc['inbox']); $test->assertSame($repoUrl, $doc['ticketsTrackedBy']); }); $test->it('フォローを受け付けてAcceptを返すはず', function($test) use ($alice, $repoUrl, $queue): void { $queue(); [$code] = Federation::receive('owner001', 'fedi1', ['id' => "{$alice}#f1", 'type' => 'Follow', 'actor' => $alice, 'object' => $repoUrl]); $test->assertSame(202, $code); $test->assertTrue(isset(Federation::followers('owner001', 'fedi1')[$alice])); $q = $queue(); $test->assertSame([['gitler:owner001/fedi1', "{$alice}/inbox", 'Accept']], array_map(fn($x) => array_slice($x, 0, 3), $q)); $test->assertSame(403, Federation::receive('owner001', 'fedi1', ['type' => 'Follow', 'actor' => $repoUrl, 'object' => $repoUrl])[0], 'ローカルを名乗る'); $test->assertSame(404, Federation::receive('owner001', 'hidden', ['type' => 'Follow', 'actor' => $alice, 'object' => $repoUrl])[0], '非公開'); }); $test->it('メンションでイシューを作り、返信をコメントにするはず', function($test) use ($alice, $bob, $repoUrl, $queue): void { $mention = '@owner001.fedi1'; $note = ['id' => 'https://8.8.8.8/notes/1', 'type' => 'Note', 'attributedTo' => $alice, 'content' => "

{$mention} 起動しない
ログ:何も出ない

", 'tag' => [['type' => 'Mention', 'href' => $repoUrl]]]; Federation::receive('owner001', 'fedi1', ['type' => 'Create', 'actor' => $alice, 'object' => $note]); $db = Issues::open('owner001', 'fedi1'); $i = $db->get(1); $test->assertSame('起動しない', $i['title']); $test->assertSame($alice, $i['author']['actor']); $test->assertSame('Alice', $i['author']['name']); $test->assertSame('@alice@8.8.8.8', $i['author']['handle']); $test->assertFalse(str_contains($i['body'], 'script')); $test->assertFalse(str_contains($i['body'], 'owner001.fedi1'), 'メンションは外す'); $q = $queue(); $test->assertSame(['Create'], array_values(array_unique(array_column($q, 2)))); $test->assertSame('https://lb.test/gitler/owner001/fedi1/issues/1', $q[0][3]['object']['id']); $test->assertSame([$alice], array_column($q[0][3]['object']['tag'], 'href'), '作った人をメンション'); // 同じ投稿は2回作らない Federation::receive('owner001', 'fedi1', ['type' => 'Create', 'actor' => $alice, 'object' => $note]); $test->assertSame(1, count($db->index()['issues'])); // メンションの無い投稿は無視 Federation::receive('owner001', 'fedi1', ['type' => 'Create', 'actor' => $alice, 'object' => ['id' => 'https://8.8.8.8/notes/2', 'type' => 'Note', 'attributedTo' => $alice, 'content' => '

関係無い

']]); $test->assertSame(1, count($db->index()['issues'])); // 返信 $reply = ['id' => 'https://9.9.9.9/notes/9', 'type' => 'Note', 'attributedTo' => $bob, 'inReplyTo' => "{$repoUrl}/issues/1", 'content' => "

{$mention} 私もです

"]; Federation::receive('owner001', 'fedi1', ['type' => 'Create', 'actor' => $bob, 'object' => $reply]); Federation::receive('owner001', 'fedi1', ['type' => 'Create', 'actor' => $bob, 'object' => $reply]); $c = $db->get(1)['comments']; $test->assertSame(1, count($c)); $test->assertSame('

私もです

', $c[0]['body']); // 返信への返信も同じイシュー Federation::receive('owner001', 'fedi1', ['type' => 'Create', 'actor' => $alice, 'object' => ['id' => 'https://8.8.8.8/notes/3', 'type' => 'Note', 'attributedTo' => $alice, 'inReplyTo' => $reply['id'], 'content' => '

ありがとう

']]); $test->assertSame(2, count($db->get(1)['comments'])); // 他人の投稿を名乗る $test->assertSame(403, Federation::receive('owner001', 'fedi1', ['type' => 'Create', 'actor' => $alice, 'object' => ['id' => 'https://9.9.9.9/notes/10', 'type' => 'Note', 'attributedTo' => $bob, 'inReplyTo' => "{$repoUrl}/issues/1", 'content' => 'x']])[0]); // 編集と削除は書いた人だけ Federation::receive('owner001', 'fedi1', ['type' => 'Update', 'actor' => $alice, 'object' => $reply + ['content' => '

乗っ取り

']]); $test->assertSame('

私もです

', $db->get(1)['comments'][0]['body']); Federation::receive('owner001', 'fedi1', ['type' => 'Update', 'actor' => $bob, 'object' => ['content' => '

私もです(追記)

'] + $reply]); $test->assertSame('

私もです(追記)

', $db->get(1)['comments'][0]['body']); Federation::receive('owner001', 'fedi1', ['type' => 'Delete', 'actor' => $bob, 'object' => ['id' => $reply['id'], 'type' => 'Tombstone']]); $test->assertSame(1, count($db->get(1)['comments'])); $queue(); }); $test->it('ローカルのコメントと閉じた事を送り、作った人のResolveで閉じるはず', function($test) use ($alice, $bob, $repoUrl, $queue): void { $db = Issues::open('owner001', 'fedi1'); $meta = Gitler::meta('owner001', 'fedi1'); $res = $db->comment(1, ['user' => 'owner001'], "直します #1"); Federation::commentAdded('owner001', 'fedi1', $meta, $res->data['issue'], $res->data['comment']); $q = $queue(); $test->assertSame('Create', $q[0][2]); $obj = $q[0][3]['object']; $test->assertSame("{$repoUrl}/issues/1/comments/".$res->data['comment']['id'], $obj['id']); $test->assertSame("{$repoUrl}/issues/1", $obj['inReplyTo']); $test->assertTrue(str_contains($obj['content'], 'href="https://lb.test/gitler/owner001/fedi1/issues/1"'), '絶対URL'); $inboxes = array_unique(array_column($q, 1)); sort($inboxes); $test->assertSame(['https://8.8.8.8/inbox'], $inboxes, 'フォロワーと関わった人(共有の受付ボックス)'); // Resolveは作った人だけ Federation::receive('owner001', 'fedi1', ['type' => 'Resolve', 'actor' => $bob, 'object' => "{$repoUrl}/issues/1/ticket"]); $test->assertSame('open', $db->get(1)['state']); Federation::receive('owner001', 'fedi1', ['type' => 'Resolve', 'actor' => $alice, 'object' => "{$repoUrl}/issues/1/ticket"]); $test->assertSame('closed', $db->get(1)['state']); $types = array_column($queue(), 2); $test->assertTrue(in_array('Resolve', $types, true) && in_array('Update', $types, true)); Federation::receive('owner001', 'fedi1', ['type' => 'Undo', 'actor' => $alice, 'object' => ['type' => 'Resolve', 'actor' => $alice, 'object' => "{$repoUrl}/issues/1/ticket"]]); $test->assertSame('open', $db->get(1)['state']); $test->assertTrue(in_array('Undo', array_column($queue(), 2), true)); }); $test->it('ForgeFedのOffer{Ticket}でイシューを作ってAcceptを返すはず', function($test) use ($bob, $repoUrl, $queue): void { $offer = ['id' => 'https://9.9.9.9/offers/1', 'type' => 'Offer', 'actor' => $bob, 'target' => $repoUrl, 'object' => ['type' => 'Ticket', 'attributedTo' => $bob, 'summary' => 'Tickets & more', 'content' => '

x

', 'source' => ['mediaType' => 'text/markdown', 'content' => "**太字**\n"]]]; Federation::receive('owner001', 'fedi1', $offer); $i = Issues::open('owner001', 'fedi1')->get(2); $test->assertSame('Tickets & more', $i['title']); $test->assertSame("**太字**\n", $i['body']); $test->assertFalse($i['html'], 'Markdownのまま'); $accept = array_values(array_filter($queue(), fn($x) => $x[2] === 'Accept')); $test->assertSame("{$repoUrl}/issues/2/ticket", $accept[0][3]['result']); $t = Federation::ticket('owner001', 'fedi1', $i); $test->assertSame('Ticket', $t['type']); $test->assertSame($bob, $t['attributedTo']); $test->assertFalse($t['isResolved']); }); $test->it('ブロックした人は無視し、SSHのプッシュでは配信を溜めるはず', function($test) use ($bob, $repoUrl, $queue): void { $test->assertTrue(Federation::block('owner001', 'fedi1', $bob)->isSuccess); $test->assertTrue(Federation::block('owner001', 'fedi1', 'spam.example')->isSuccess); $test->assertTrue(Federation::isBlocked('owner001', 'fedi1', 'https://a.spam.example/u/x')); $before = count(Issues::open('owner001', 'fedi1')->get(1)['comments']); Federation::receive('owner001', 'fedi1', ['type' => 'Create', 'actor' => $bob, 'object' => ['id' => 'https://9.9.9.9/notes/20', 'type' => 'Note', 'attributedTo' => $bob, 'inReplyTo' => "{$repoUrl}/issues/1", 'content' => '

x

']]); $test->assertSame($before, count(Issues::open('owner001', 'fedi1')->get(1)['comments'])); Federation::block('owner001', 'fedi1', $bob, false); $test->assertFalse(Federation::isBlocked('owner001', 'fedi1', $bob)); $queue(); Gitler::$deferred = true; $issue = Issues::open('owner001', 'fedi1')->get(1); Federation::stateChanged('owner001', 'fedi1', Gitler::meta('owner001', 'fedi1'), $issue); Gitler::$deferred = false; $test->assertSame([], $queue()); $test->assertSame(2, count(glob(Gitler::path('owner001', 'fedi1').'/gitler/spool/*.json'))); $test->assertSame(2, Gitler::flushDeferred()); $test->assertSame([], glob(Gitler::path('owner001', 'fedi1').'/gitler/spool/*.json')); $test->assertTrue(count($queue()) > 0); }); Fedi::$dataDir = null; Fedi::$baseUrl = null; }); $test->describe('Gitler(通知とアーカイブ)', function($test) use ($base, $owner, $other, $plain, $admin): void { Notifications::$dataDir = "{$base}/data"; Gitler::create($owner, 'notify1'); $meta = Gitler::meta('owner001', 'notify1'); $meta['collaborators'] = ['other001' => 'write']; Gitler::saveMeta('owner001', 'notify1', $meta); $meta = Gitler::meta('owner001', 'notify1'); $db = Issues::open('owner001', 'notify1'); $labels = fn(string $u) => array_map(fn($n) => $n['label'], Notifications::list($u)); $clear = function(): void { foreach (glob(Notifications::$dataDir.'/notifications/*.json') ?: [] as $f) unlink($f); }; $test->it('「@ユーザー」を見つけるはず(コード、リモート、リポジトリは除く)', function($test): void { $test->assertSame(['alice', 'bob'], Notify::mentions("@alice さん、@bob。\n`@code` @x@example.com @owner.repo mail@example.com\n```\n@block\n```\n")); }); $test->it('所有者と共同作業者はウォッチし、作った人は自分の通知を受け取らないはず', function($test) use ($meta, $db, $labels, $clear): void { $clear(); $test->assertSame(Notify::WATCHING, Notify::watchState('owner001', 'notify1', 'owner001')); $test->assertSame(Notify::PARTICIPATING, Notify::watchState('owner001', 'notify1', 'plain001')); $test->assertSame(['owner001', 'other001'], Notify::watchers('owner001', 'notify1', $meta)); $issue = $db->create(['user' => 'plain001'], '動かない', '@admin001 見て下さい')->data['issue']; Notify::issueOpened('owner001', 'notify1', $meta, $db, $issue); $test->assertSame(['がowner001/notify1にイシュー#1「動かない」を作りました'], $labels('owner001')); $test->assertSame(1, count($labels('other001'))); $test->assertSame([], $labels('plain001'), '自分の操作'); $test->assertSame(['がowner001/notify1#1「動かない」であなたをメンションしました'], $labels('admin001')); $test->assertSame(true, $db->get(1)['subscribers']['admin001'], 'メンションされた人は受け取る'); $n = Notifications::list('owner001')[0]; $test->assertSame(['username' => 'plain001'], $n['from']); $test->assertSame('/gitler/owner001/notify1/issues/1', $n['page']); $test->assertSame('@admin001 見て下さい', $n['excerpt']); $test->assertSame(1, Notifications::unreadCount('owner001')); Notifications::markRead('owner001'); $test->assertSame(0, Notifications::unreadCount('owner001')); }); $test->it('コメントは受け取る人に届き、止めた人と無視した人には届かないはず', function($test) use ($meta, $db, $labels, $clear): void { $clear(); Notify::setWatch('owner001', 'notify1', 'other001', Notify::IGNORING); $db->subscribe(1, ['admin001'], false); $res = $db->comment(1, ['user' => 'owner001'], '直します'); Notify::commented('owner001', 'notify1', $meta, $db, $res->data['issue'], $res->data['comment']); $test->assertSame(['がowner001/notify1#1「動かない」にコメントしました'], $labels('plain001')); $test->assertSame('/gitler/owner001/notify1/issues/1#c1', Notifications::list('plain001')[0]['page']); $test->assertSame([], $labels('other001'), '無視'); $test->assertSame([], $labels('admin001'), '止めた'); $test->assertSame([], $labels('owner001'), '自分'); // コミットで閉じる $clear(); $issue = $db->setState(1, false, ['commit' => str_repeat('a', 40), 'name' => '諏訪子']); Notify::stateChanged('owner001', 'notify1', $meta, $issue, ['commit' => str_repeat('a', 40), 'name' => '諏訪子']); $test->assertSame(['のコミットaaaaaaaがowner001/notify1#1「動かない」を閉じました'], $labels('owner001')); $test->assertSame(['name' => '諏訪子'], Notifications::list('owner001')[0]['from']); $test->assertSame(1, count($labels('plain001'))); // 担当者 $clear(); $new = $db->triage(1, ['user' => 'owner001'], null, ['other001'], false); Notify::assigned('owner001', 'notify1', $meta, $new, ['other001'], ['user' => 'owner001']); $test->assertSame([], $labels('other001'), '無視しているので担当も届かない'); Notify::setWatch('owner001', 'notify1', 'other001', Notify::PARTICIPATING); Notify::assigned('owner001', 'notify1', $meta, $new, ['other001'], ['user' => 'owner001']); $test->assertSame(['がowner001/notify1#1「動かない」の担当者にあなたを追加しました'], $labels('other001')); }); $test->it('SSHのプッシュでは通知を溜めて後で届けるはず', function($test) use ($meta, $db, $labels, $clear): void { $clear(); Gitler::$deferred = true; $issue = $db->setState(1, true, ['user' => 'owner001']); Notify::stateChanged('owner001', 'notify1', $meta, $issue, ['user' => 'owner001']); Gitler::$deferred = false; $test->assertSame([], $labels('plain001')); $test->assertTrue(Gitler::flushDeferred() >= 1); $test->assertSame(['がowner001/notify1#1「動かない」を開き直しました'], $labels('plain001')); }); $test->it('メールの設定を保存し、アーカイブではイシューとFediverseのコメントを受け付けないはず', function($test) use ($meta, $db): void { $test->assertTrue(Keys::load('plain001')['email']); $test->assertTrue(Keys::setEmail('plain001', false)); $test->assertFalse(Keys::load('plain001')['email']); $test->assertSame([], Keys::load('plain001')['keys'], '鍵は消えない'); $test->assertFalse($meta['archived']); $meta['archived'] = true; Gitler::saveMeta('owner001', 'notify1', $meta); $test->assertTrue(Gitler::meta('owner001', 'notify1')['archived']); if (Fedi::enabled()) { $res = Federation::receive('owner001', 'notify1', ['type' => 'Create', 'actor' => 'https://8.8.8.8/users/alice', 'object' => ['id' => 'https://8.8.8.8/notes/x', 'type' => 'Note', 'attributedTo' => 'https://8.8.8.8/users/alice', 'inReplyTo' => 'https://lb.test/gitler/owner001/notify1/issues/1', 'content' => '

x

']]); $test->assertSame('アーカイブされています', $res[1]['status']); } $test->assertSame(1, count($db->get(1)['comments'])); }); Notifications::$dataDir = null; }); $test->describe('Gitler(プルリクエストとフォーク)', function($test) use ($base, $admin, $owner, $mkUser): void { Notifications::$dataDir = "{$base}/data"; $forker = $mkUser(7, 'forker01', Roles::MEMBER | Roles::GIT_REPOS | Roles::GIT_PULLS); $reader = $mkUser(8, 'reader01', Roles::MEMBER); Gitler::create($admin, 'base1'); $repo = Gitler::open('admin001', 'base1'); $me = new Person('諏訪子', 's@x', 1700000000, '+0900'); $commit = function(Repository $r, string $branch, array $files, string $msg) use ($me): string { $parent = $r->refs->resolve('refs/heads/'.$branch); $c = $r->writeCommit(Commit::create($r->updateTree($parent ? $r->commit($parent)->tree : null, $files), $parent ? [$parent] : [], $me, $me, $msg."\n")); $r->refs->update('refs/heads/'.$branch, $c); return $c; }; $c0 = $commit($repo, 'master', ['a.txt' => [Tree::FILE, "1\n2\n3\n4\n5\n"]], '最初'); $issues = new Issues($repo); $bug = $issues->create(['user' => 'admin001'], 'バグ', '')->data['number']; $test->it('同じリポジトリのブランチからプルリクエストを作ってマージするはず', function($test) use ($repo, $commit, $admin, $issues, $bug): void { $repo->refs->update('refs/heads/topic', $repo->resolve('master')); $head = $commit($repo, 'topic', ['b.txt' => [Tree::FILE, "b\n"]], 'bを追加'); $test->assertFalse(Pulls::open('admin001', 'base1', ['user' => 'admin001'], 'x', '', 'master', 'branch', ['owner' => 'admin001', 'name' => 'base1', 'branch' => 'master'], $repo->resolve('master'))->isSuccess, '差分が無い'); $res = Pulls::open('admin001', 'base1', ['user' => 'admin001'], 'bを追加', "fixes #{$bug}", 'master', 'branch', ['owner' => 'admin001', 'name' => 'base1', 'branch' => 'topic'], $head); $test->assertTrue($res->isSuccess, (string)$res->message); $n = $res->data['number']; $test->assertSame($bug + 1, $n, 'イシューと同じ番号'); $test->assertSame($head, $repo->refs->resolve(Pulls::ref($n))); $test->assertSame([$n], array_column($issues->search(['pull' => true]), 'number')); $test->assertSame([$bug], array_column($issues->search(), 'number'), 'イシューの一覧には出ない'); $test->assertSame(['open' => 1, 'closed' => 0], $issues->counts(true)); $test->assertFalse(Pulls::open('admin001', 'base1', ['user' => 'admin001'], 'y', '', 'master', 'branch', ['owner' => 'admin001', 'name' => 'base1', 'branch' => 'topic'], $head)->isSuccess, '同じ物'); // ブランチにプッシュすると更新 $head2 = $commit($repo, 'topic', ['c.txt' => [Tree::FILE, "c\n"]], 'cを追加'); Pulls::afterPush('admin001', 'base1', $admin, [['refs/heads/topic', $head, $head2]]); $i = $issues->get($n); $test->assertSame($head2, $i['pull']['headId']); $test->assertSame(1, end($i['events'])['count']); $test->assertSame('ff', Pulls::mergeability($repo, $i)['state']); $old = $repo->resolve('master'); $res = Pulls::merge('admin001', 'base1', $n, $admin, 'merge', ''); $test->assertTrue($res->isSuccess, (string)$res->message); $m = $repo->commit($repo->resolve('master')); $test->assertSame([$old, $head2], $m->parents); $test->assertSame("Merge pull request #{$n}", $m->subject()); $i = $issues->get($n); $test->assertSame('closed', $i['state']); $test->assertSame('merge', $i['pull']['merged']['method']); $test->assertSame('closed', $issues->get($bug)['state'], '本文の「fixes」'); $test->assertSame([$old, $head2], Pulls::range($repo, $i)); $test->assertFalse(Pulls::merge('admin001', 'base1', $n, $admin, 'merge', '')->isSuccess, '2回目'); }); $test->it('コンフリクトを見つけ、スカッシュとリベースでマージするはず', function($test) use ($repo, $commit, $admin, $issues): void { $mk = function(string $branch, array $files) use ($repo, $commit): string { $repo->refs->update('refs/heads/'.$branch, $repo->resolve('master')); return $commit($repo, $branch, $files, $branch); }; $open = fn(string $branch, string $head) => Pulls::open('admin001', 'base1', ['user' => 'admin001'], $branch, '', 'master', 'branch', ['owner' => 'admin001', 'name' => 'base1', 'branch' => $branch], $head)->data['number']; $h1 = $mk('one', ['a.txt' => [Tree::FILE, "1\nONE\n3\n4\n5\n"]]); $h2 = $mk('two', ['a.txt' => [Tree::FILE, "1\nTWO\n3\n4\n5\n"]]); $h3 = $commit($repo, 'one', ['d.txt' => [Tree::FILE, "d\n"]], 'one-2'); $h4 = $mk('three', ['a.txt' => [Tree::FILE, "1\n2\n3\n4\nTHREE\n"]]); $n1 = $open('one', $h3); $n2 = $open('two', $h2); $n4 = $open('three', $h4); $base = $repo->resolve('master'); $test->assertTrue(Pulls::merge('admin001', 'base1', $n1, $admin, 'squash', '')->isSuccess); $sq = $repo->commit($repo->resolve('master')); $test->assertSame([$base], $sq->parents, 'スカッシュは1つのコミット'); $test->assertSame("one (#{$n1})", $sq->subject()); $test->assertSame('諏訪子', $repo->commit($h3)->author->name); $test->assertSame('conflict', Pulls::mergeability($repo, $issues->get($n2))['state']); $test->assertSame(['a.txt'], Pulls::mergeability($repo, $issues->get($n2))['conflicts']); $test->assertFalse(Pulls::merge('admin001', 'base1', $n2, $admin, 'merge', '')->isSuccess); $test->assertFalse(Pulls::merge('admin001', 'base1', $n2, $admin, 'rebase', '')->isSuccess); $test->assertSame('clean', Pulls::mergeability($repo, $issues->get($n4))['state']); $before = $repo->resolve('master'); $test->assertTrue(Pulls::merge('admin001', 'base1', $n4, $admin, 'rebase', '')->isSuccess); $rb = $repo->commit($repo->resolve('master')); $test->assertSame([$before], $rb->parents); $test->assertSame('three', $rb->subject()); $test->assertSame("1\nONE\n3\n4\nTHREE\n", $repo->blob($repo->resolve('master:a.txt'))); }); $test->it('フォークしてプルリクエストを作り、フォークへのプッシュで更新するはず', function($test) use ($repo, $commit, $forker, $reader, $issues): void { $test->assertFalse(Gitler::fork($reader, 'admin001', 'base1')->isSuccess, 'ロールが無い'); $res = Gitler::fork($forker, 'admin001', 'base1'); $test->assertTrue($res->isSuccess, (string)$res->message); $test->assertSame('base1', $res->data['name']); $test->assertSame('admin001/base1', Gitler::meta('forker01', 'base1')['forkOf']); $test->assertSame([['forker01', 'base1']], Gitler::forks('admin001', 'base1')); $test->assertSame('base1', Gitler::fork($forker, 'admin001', 'base1')->data['name'], '2回目は同じフォーク'); $fork = Gitler::open('forker01', 'base1'); $test->assertSame($repo->resolve('master'), $fork->resolve('master')); $test->assertSame([], $fork->refs->all('refs/pull/')); $f1 = $commit($fork, 'feature', ['f.txt' => [Tree::FILE, "f\n"]], 'フォークで追加'); $test->assertFalse($repo->objects->exists($f1)); Pulls::fetch($fork, $repo, $f1); $test->assertTrue($repo->objects->exists($f1)); $res = Pulls::open('admin001', 'base1', ['user' => 'forker01'], 'フォークから', '', 'master', 'fork', ['owner' => 'forker01', 'name' => 'base1', 'branch' => 'feature'], $f1); $test->assertTrue($res->isSuccess, (string)$res->message); $n = $res->data['number']; $f2 = $commit($fork, 'feature', ['f.txt' => [Tree::FILE, "f2\n"]], 'もう1つ'); $msgs = Pulls::afterPush('forker01', 'base1', $forker, [['refs/heads/feature', $f1, $f2]]); $test->assertSame($f2, $issues->get($n)['pull']['headId']); $test->assertTrue($repo->objects->exists($f2)); $test->assertSame([], $msgs); $msgs = Pulls::afterPush('forker01', 'base1', $forker, [['refs/heads/new', $fork->zeroId(), $f2]]); $test->assertStringContains('/gitler/admin001/base1/compare?head=forker01%2Fbase1%3Anew', $msgs[0] ?? ''); }); $test->it('refs/for/へのプッシュ(AGit)でプルリクエストを作って更新するはず', function($test) use ($repo, $forker, $reader, $issues, $me): void { $zero = $repo->zeroId(); $test->assertSame('pull requests are not allowed', Pulls::checkAgit('admin001', 'base1', $reader, 'refs/for/master/x', str_repeat('1', 40), $zero)); $test->assertSame('use refs/for//', Pulls::checkAgit('admin001', 'base1', $forker, 'refs/for/nope/x', str_repeat('1', 40), $zero)); $test->assertNull(Pulls::checkAgit('admin001', 'base1', $forker, 'refs/for/master/fix/typo', str_repeat('1', 40), $zero)); $test->assertSame(['master', 'fix/typo'], Pulls::parseAgit($repo, 'refs/for/master/fix/typo')); $master = $repo->resolve('master'); $a1 = $repo->writeCommit(Commit::create($repo->updateTree($repo->commit($master)->tree, ['typo.txt' => [Tree::FILE, "x\n"]]), [$master], $me, $me, "誤字を直す\n\n説明\n")); $msgs = Pulls::afterPush('admin001', 'base1', $forker, [['refs/for/master/fix/typo', $zero, $a1]]); $test->assertStringContains('を作りました', $msgs[0]); $n = (int)preg_replace('/^.*#(\d+).*$/u', '$1', $msgs[0]); $i = $issues->get($n); $test->assertSame('誤字を直す', $i['title']); $test->assertSame('説明', $i['body']); $test->assertSame(['user' => 'forker01', 'topic' => 'fix/typo'], $i['pull']['head']); $a2 = $repo->writeCommit(Commit::create($repo->commit($a1)->tree, [$a1], $me, $me, "2\n")); $msgs = Pulls::afterPush('admin001', 'base1', $forker, [['refs/for/master/fix/typo', $zero, $a2]]); $test->assertStringContains("#{$n}を更新しました", $msgs[0]); $test->assertSame($a2, $repo->refs->resolve(Pulls::ref($n))); // マージ先に入ったらマージ済み $old = $repo->resolve('master'); $repo->refs->update('refs/heads/master', $a2); Pulls::afterPush('admin001', 'base1', null, [['refs/heads/master', $old, $a2]]); $test->assertSame('manual', $issues->get($n)['pull']['merged']['method']); }); $test->it('行へのコメントの下書きをレビューに入れるはず', function($test) use ($issues): void { $n = $issues->search(['pull' => true, 'state' => 'all'])[0]['number']; $at = ['path' => 'a.txt', 'side' => 'new', 'line' => 2, 'commit' => 'x', 'hunk' => [' 1', '+2'], 'pending' => true]; $c = $issues->inlineComment($n, ['user' => 'reader01'], 'ここ', $at)->data['comment']; $test->assertTrue($c['pending']); $before = $issues->index()['issues'][$n]['comments']; $test->assertFalse($issues->submitReview($n, ['user' => 'other001'], 'commented', '', 'x')->isSuccess, '空のレビュー'); $r = $issues->submitReview($n, ['user' => 'reader01'], 'approved', 'いいね', 'x'); $test->assertTrue($r->isSuccess); $i = $issues->get($n); $test->assertSame(1, $i['reviews'][0]['id']); $test->assertFalse(end($i['comments'])['pending']); $test->assertSame(1, end($i['comments'])['review']); $test->assertSame($before + 1, $issues->index()['issues'][$n]['comments']); $reply = $issues->inlineComment($n, ['user' => 'admin001'], '直します', ['replyTo' => $c['id']])->data['comment']; $test->assertSame(['a.txt', 2, $c['id']], [$reply['path'], $reply['line'], $reply['replyTo']]); }); Notifications::$dataDir = null; }); $test->describe('Gitler(HTTPのパスワード)', function($test) use ($base): void { if (!AUTH_ENABLED) { $test->skipAll('AUTH_ENABLEDが無効'); return; } $_SERVER['REMOTE_ADDR'] ??= '127.0.0.1'; $test->it('パスワードだけを確かめ、二段階認証のユーザーは使えないはず', function($test) use ($base): void { $pass = 'testTEST1234!#$%@[]:;'; $auth = new class($base) extends Auth { public function __construct(string $base) { $this->dataDir = "{$base}/user/"; $this->assDir = "{$base}/ass/"; parent::__construct(); } public function twoFactorAvailable(): bool { return true; } }; $u = ['id' => 10, 'username' => 'pass0001', 'email' => 'p@x', 'role' => Roles::MEMBER, 'password' => password_hash($pass, PASSWORD_DEFAULT), 'tokens' => []]; file_put_contents("{$base}/user/10.pass0001.json", json_encode($u)); $ok = $auth->checkPassword('pass0001', $pass); $test->assertSame('pass0001', $ok?->username); $test->assertFalse(isset($ok->password)); $test->assertNull($auth->checkPassword('pass0001', 'machigatta')); $test->assertNull($auth->checkPassword('nobody01', $pass)); $u['twoFactor'] = ['secret' => 'ABC']; file_put_contents("{$base}/user/10.pass0001.json", json_encode($u)); $test->assertNull($auth->checkPassword('pass0001', $pass), '二段階認証'); $auth->clearLoginFailures(); }); }); $test->printSummary();