make(\Illuminate\Contracts\Console\Kernel::class)->bootstrap(); return $app; } private string $root; protected function setUp(): void { parent::setUp(); \Illuminate\Support\Facades\URL::forceRootUrl('http://ttc.loc'); Http::preventStrayRequests(); $this->root = sys_get_temp_dir().'/ttc-content-test-'.Str::uuid(); File::makeDirectory($this->root.'/vuepress/node_modules/.bin', 0755, true); File::makeDirectory($this->root.'/vuepress/docs/jccp', 0755, true); File::makeDirectory($this->root.'/laravel/storage/app/weekly-ranking', 0755, true); file_put_contents($this->root.'/vuepress/node_modules/.bin/vuepress', ' 'local', 'content_center.enabled' => true, 'content_center.atom_key' => str_repeat('k', 40), 'content_center.atom_url' => 'http://atom.loc', 'content_center.site_root' => $this->root, 'content_center.node' => PHP_BINARY, 'cache.default' => 'array']); } protected function tearDown(): void { File::deleteDirectory($this->root); parent::tearDown(); } private function input(int $generation = 1): array { return ['source_key' => 'telegram:-1001234567890:album:99', 'revision' => str_repeat('a', 64), 'generation' => $generation, 'title' => '测评标题', 'text' => ' {{ danger }}', 'sent_at' => '2026-10-24 08:01:01', 'images' => []]; } private function signed(string $path, array $data, ?string $nonce = null) { $body = json_encode($data, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES); $time = (string) time(); $nonce ??= bin2hex(random_bytes(16)); $sig = hash_hmac('sha256', $time."\n".$nonce."\nPOST\n".$path."\n".hash('sha256', $body), str_repeat('k', 40)); return $this->call('POST', $path, [], [], [], ['CONTENT_TYPE' => 'application/json', 'HTTP_ACCEPT' => 'application/json', 'HTTP_X_CONTENT_TIME' => $time, 'HTTP_X_CONTENT_NONCE' => $nonce, 'HTTP_X_CONTENT_ENVIRONMENT' => 'local', 'HTTP_X_CONTENT_SIGNATURE' => $sig], $body); } public function test_preview_authenticates_and_does_not_write_or_build(): void { $this->postJson('/api/content/v1/preview', $this->input())->assertUnauthorized(); $nonce = bin2hex(random_bytes(16)); $response = $this->signed('/api/content/v1/preview', $this->input(), $nonce)->assertOk(); $markdown = $response->json('artifact.markdown'); $this->assertStringContainsString('<script>', $markdown); $this->assertStringContainsString('
', $markdown); $this->assertSame([], File::files($this->root.'/vuepress/docs/jccp')); $this->signed('/api/content/v1/preview', $this->input(), $nonce)->assertStatus(409); } public function test_legacy_receive_cannot_bypass_content_review(): void { $this->postJson('/api/article/receive/schema', ['cmd' => 'new'])->assertStatus(410); $this->assertSame([], File::files($this->root.'/vuepress/docs/jccp')); } public function test_wrong_hash_cannot_publish(): void { $this->signed('/api/content/v1/publish', ['approval_id' => (string) Str::uuid(), 'approved_hash' => str_repeat('0', 64), 'input' => $this->input()])->assertStatus(409); $this->assertSame([], File::files($this->root.'/vuepress/docs/jccp')); } public function test_approved_publication_is_idempotent_and_old_generation_cannot_overwrite_new(): void { $input = $this->input(20); $artifact = app(ArticleArtifact::class)->build($input); $payload = ['approval_id' => (string) Str::uuid(), 'approved_hash' => ArticleArtifact::hash($artifact), 'input' => $input]; $one = $this->signed('/api/content/v1/publish', $payload)->assertOk()->assertJsonPath('receipt.status', 'published'); $this->signed('/api/content/v1/publish', $payload)->assertOk()->assertJsonPath('receipt.approval_id', $payload['approval_id']); $this->assertSame(1, count(File::files($this->root.'/vuepress/docs/jccp'))); $old = $this->input(10); $this->signed('/api/content/v1/publish', ['approval_id' => (string) Str::uuid(), 'approved_hash' => ArticleArtifact::hash(app(ArticleArtifact::class)->build($old)), 'input' => $old])->assertStatus(409); } public function test_failed_build_restores_source_and_preserves_previous_dist(): void { File::makeDirectory($this->root.'/vuepress/docs/.vuepress/dist', 0755, true); file_put_contents($this->root.'/vuepress/docs/.vuepress/dist/index.html', 'previous'); file_put_contents($this->root.'/vuepress/node_modules/.bin/vuepress', 'input(); $artifact = app(ArticleArtifact::class)->build($input); try { app(Publication::class)->publish(['approval_id' => (string) Str::uuid(), 'approved_hash' => ArticleArtifact::hash($artifact), 'input' => $input]); $this->fail('Expected build failure'); } catch (\RuntimeException) { $this->assertFileDoesNotExist($this->root.'/vuepress/docs/'.$artifact['relative_path']); $this->assertSame('previous', file_get_contents($this->root.'/vuepress/docs/.vuepress/dist/index.html')); } } }