110 lines
5.7 KiB
PHP
110 lines
5.7 KiB
PHP
<?php
|
|
|
|
namespace Tests\Feature;
|
|
|
|
use App\ContentCenter\ArticleArtifact;
|
|
use App\ContentCenter\Publication;
|
|
use Illuminate\Support\Facades\File;
|
|
use Illuminate\Support\Facades\Http;
|
|
use Illuminate\Support\Str;
|
|
use Tests\TestCase;
|
|
|
|
final class ContentCenterTest extends TestCase
|
|
{
|
|
public function createApplication()
|
|
{
|
|
$app = require dirname(__DIR__, 2).'/bootstrap/app.php';
|
|
$app->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', '<?php $dest=$argv[array_search("--dest",$argv)+1];mkdir($dest,0755,true);file_put_contents($dest."/index.html","isolated test build");');
|
|
config(['content_center.environment' => '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' => '<script>bad()</script> {{ 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('<div v-pre>', $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', '<?php exit(1);');
|
|
$input = $this->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'));
|
|
}
|
|
}
|
|
}
|