コードスタディプラグインは廃止されました。
' + . 'このフレームは固定記事に置き換えています。
' + . '必要な情報がある場合は、サイト管理者にお問い合わせください。
'; + } +} diff --git a/database/migrations/2026_02_18_000000_remove_codestudies_from_plugins.php b/database/migrations/2026_02_18_000000_remove_codestudies_from_plugins.php new file mode 100644 index 000000000..1233f6705 --- /dev/null +++ b/database/migrations/2026_02_18_000000_remove_codestudies_from_plugins.php @@ -0,0 +1,29 @@ +whereRaw('LOWER(plugin_name) = ?', ['codestudies']) + ->delete(); + } + + /** + * Reverse the migrations. + * + * @return void + */ + public function down() + { + // 削除済みプラグインの再表示を防ぐため、復元はしない。 + } +} diff --git a/package-lock.json b/package-lock.json index d6a1f5aab..6c52474ee 100644 --- a/package-lock.json +++ b/package-lock.json @@ -8130,9 +8130,9 @@ "license": "MIT" }, "node_modules/minimatch": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", - "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", + "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", "dev": true, "license": "ISC", "dependencies": { diff --git a/resources/views/plugins/manage/page/migration_order.blade.php b/resources/views/plugins/manage/page/migration_order.blade.php index 5dc00c8c7..a4ff3bd50 100644 --- a/resources/views/plugins/manage/page/migration_order.blade.php +++ b/resources/views/plugins/manage/page/migration_order.blade.php @@ -73,6 +73,22 @@ + @if (config('connect.HTTPPROXYTUNNEL')) +ok
', + 'http_code' => 200, + 'location' => '', + 'content_disposition' => '', + ]); + + $url = 'http://8.8.8.8/source/index.html'; + $page_id = 1002; + $target->runMigrationHtmlPage($url, $page_id); + + $this->assertSame([$url], $target->requestedGetUrls()); + $this->assertFrameSavedContains($page_id, 'ok
'); + } + + /** + * DOMDocumentの解析警告が発生するHTMLでも取り込みを継続して保存すること + * + * @return void + */ + public function testImportContinuesWhenDomDocumentEmitsHtmlParseWarnings() + { + $this->fakeMigrationStorage(); + + $target = new TestableMigrationExportHtmlPageTraitTarget(); + $target->queueGetResponse([ + 'body' => 'before &nobr; after
ok
', + 'http_code' => 200, + 'location' => '', + 'content_disposition' => '', + ]); + + $url = 'http://8.8.8.8/source/invalid-entity.html'; + $page_id = 1006; + + $target->runMigrationHtmlPage($url, $page_id); + + $this->assertSame([$url], $target->requestedGetUrls()); + $this->assertFrameSavedContains($page_id, 'ok
'); + } + + /** + * リダイレクト先が非グローバルURLなら保存せずに中断すること + * + * @return void + */ + public function testImportStopsWhenRedirectDestinationIsNonGlobal() + { + $this->fakeMigrationStorage(); + + $target = new TestableMigrationExportHtmlPageTraitTarget(); + $target->queueGetResponse([ + 'body' => '', + 'http_code' => 302, + 'location' => 'http://127.0.0.1/internal/index.html', + 'content_disposition' => '', + ]); + + $url = 'http://8.8.8.8/start/index.html'; + $page_id = 1003; + $target->runMigrationHtmlPage($url, $page_id); + + $this->assertSame([$url], $target->requestedGetUrls()); + $this->assertFrameNotSaved($page_id); + } + + /** + * グローバルなリダイレクト先には追従してHTMLを保存すること + * + * @return void + */ + public function testImportFollowsGlobalRedirectAndSavesHtml() + { + $this->fakeMigrationStorage(); + + $target = new TestableMigrationExportHtmlPageTraitTarget(); + $target->queueGetResponse([ + 'body' => '', + 'http_code' => 302, + 'location' => '/moved/index.html', + 'content_disposition' => '', + ]); + $target->queueGetResponse([ + 'body' => 'KeepBody
', + 'http_code' => 200, + 'location' => '', + 'content_disposition' => '', + ]); + $target->queueDownloadException(new \RuntimeException('image download failed')); + + $page_id = 1005; + $target->runMigrationHtmlPage('http://8.8.8.8/source/image-fail.html', $page_id); + + $this->assertSame(['http://8.8.8.8/source/image-fail.html'], $target->requestedGetUrls()); + $this->assertSame([$image_url], $target->requestedDownloadUrls()); + + $html = Storage::get("migration/import/pages/{$page_id}/frame_0001.html"); + $ini = Storage::get("migration/import/pages/{$page_id}/frame_0001.ini"); + + $this->assertStringContainsString('KeepBody', $html); + $this->assertStringContainsString($image_url, $html); + $this->assertStringNotContainsString('frame_0001_1.', $html); + $this->assertStringContainsString('[image_names]', $ini); + $this->assertStringNotContainsString('frame_0001_1', $ini); + } + + /** + * 移行用ストレージをfake化する + * + * @return void + */ + private function fakeMigrationStorage(): void + { + Storage::fake(config('filesystems.default', 'local')); + } + + /** + * フレームHTML/INIが保存されていないことを検証する + * + * @param int $page_id + * @return void + */ + private function assertFrameNotSaved(int $page_id): void + { + Storage::assertMissing("migration/import/pages/{$page_id}/frame_0001.html"); + Storage::assertMissing("migration/import/pages/{$page_id}/frame_0001.ini"); + } + + /** + * フレームHTML/INIが保存され、HTML内に期待文字列を含むことを検証する + * + * @param int $page_id + * @param string $expected_fragment + * @return void + */ + private function assertFrameSavedContains(int $page_id, string $expected_fragment): void + { + Storage::assertExists("migration/import/pages/{$page_id}/frame_0001.html"); + Storage::assertExists("migration/import/pages/{$page_id}/frame_0001.ini"); + $this->assertStringContainsString($expected_fragment, Storage::get("migration/import/pages/{$page_id}/frame_0001.html")); + } +} + +// phpcs:ignore PSR1.Classes.ClassDeclaration.MultipleClasses -- Test double is intentionally colocated with the test. +class TestableMigrationExportHtmlPageTraitTarget +{ + use MigrationExportHtmlPageTrait { + migrationHtmlPage as private traitMigrationHtmlPage; + } + + /** @var array */ + private $http_get_calls = []; + + /** @var array */ + private $queued_get_responses = []; + + /** @var array */ + private $download_calls = []; + + /** @var array */ + private $queued_download_results = []; + + public function runMigrationHtmlPage(string $url, int $page_id): void + { + $this->traitMigrationHtmlPage($url, $page_id); + } + + public function queueGetResponse(array $response): void + { + $this->queued_get_responses[] = $response; + } + + /** + * ダウンロード例外をキューに積む + * + * @param \RuntimeException $exception + * @return void + */ + public function queueDownloadException(\RuntimeException $exception): void + { + $this->queued_download_results[] = ['type' => 'exception', 'value' => $exception]; + } + + /** + * GETリクエストされたURL一覧を返す + * + * @return array + */ + public function requestedGetUrls(): array + { + return $this->http_get_calls; + } + + /** + * ダウンロードURL一覧を返す + * + * @return array + */ + public function requestedDownloadUrls(): array + { + return $this->download_calls; + } + + protected function migrationHttpCreateClient(array $http_options = []): Client + { + return new Client(); + } + + protected function migrationHttpGet(Client $http_client, string $url, array $http_options = []): array + { + $this->http_get_calls[] = $url; + + if (empty($this->queued_get_responses)) { + throw new \RuntimeException('No queued migrationHttpGet response.'); + } + + return array_shift($this->queued_get_responses); + } + + protected function migrationHttpDownloadToFile(Client $http_client, string $url, string $sink_path, array $http_options = []): array + { + $this->download_calls[] = $url; + + if (empty($this->queued_download_results)) { + throw new \RuntimeException('Unexpected migrationHttpDownloadToFile call in this test.'); + } + + $queued = array_shift($this->queued_download_results); + if ($queued['type'] === 'exception') { + throw $queued['value']; + } + + throw new \RuntimeException('Unsupported queued download result type.'); + } +} diff --git a/tests/Unit/Traits/Migration/MigrationExportNc3PageTraitTest.php b/tests/Unit/Traits/Migration/MigrationExportNc3PageTraitTest.php new file mode 100644 index 000000000..9fd2a5eff --- /dev/null +++ b/tests/Unit/Traits/Migration/MigrationExportNc3PageTraitTest.php @@ -0,0 +1,555 @@ +real_storage_page_ids_to_cleanup as $page_id) { + File::deleteDirectory(storage_path("app/migration/import/pages/{$page_id}")); + } + + parent::tearDown(); + } + + /** + * 非グローバルURLは事前検証で拒否し、インポートしないこと + * + * @return void + */ + public function testNonGlobalUrlIsNotImported() + { + $this->fakeMigrationStorage(); + + $target = new TestableMigrationExportNc3PageTraitTarget(); + + $target->runMigrationNc3Page('http://127.0.0.1/nc3/index.html', 2001); + + $this->assertSame([], $target->requestedGetUrls()); + $this->assertFrameNotSaved(2001, 1); + } + + /** + * ページ取得失敗時は中断し、フレームを保存しないこと + * + * @return void + */ + public function testImportStopsWhenPageFetchFails() + { + $this->fakeMigrationStorage(); + + $target = new TestableMigrationExportNc3PageTraitTarget(); + $target->queueGetException(new RuntimeException('network failed')); + + $url = 'http://8.8.8.8/nc3/index.html'; + $target->runMigrationNc3Page($url, 2002); + + $this->assertSame([$url], $target->requestedGetUrls()); + $this->assertFrameNotSaved(2002, 1); + } + + /** + * NC3の1フレームをConnect-CMS形式として保存できること + * + * @return void + */ + public function testSingleNc3SectionIsImportedAsSingleFrame() + { + $this->fakeMigrationStorage(); + + $target = new TestableMigrationExportNc3PageTraitTarget(); + $target->queueGetResponse([ + 'body' => $this->buildNc3Html([ + [ + 'id' => 'frame-101', + 'class' => 'panel panel-primary', + 'title' => 'Notice', + 'content' => 'BodyA
', + ], + ]), + 'http_code' => 200, + 'location' => '', + 'content_disposition' => '', + ]); + + $url = 'http://8.8.8.8/nc3/index.html'; + $page_id = 2003; + $target->runMigrationNc3Page($url, $page_id); + + $this->assertSame([$url], $target->requestedGetUrls()); + $this->assertNc3FrameSavedContains($page_id, 1, 'BodyA
'); + $this->assertStringContainsString('frame_title = "Notice"', Storage::get("migration/import/pages/{$page_id}/frame_0001.ini")); + $this->assertStringContainsString('source_key = "101"', Storage::get("migration/import/pages/{$page_id}/frame_0001.ini")); + } + + /** + * DOMDocumentの解析警告が発生するNC3 HTMLでも取り込みを継続して保存すること + * + * @return void + */ + public function testImportContinuesWhenDomDocumentEmitsHtmlParseWarnings() + { + $this->fakeMigrationStorage(); + + $target = new TestableMigrationExportNc3PageTraitTarget(); + $target->queueGetResponse([ + 'body' => $this->buildNc3Html([ + [ + 'id' => 'frame-111', + 'class' => 'panel panel-primary', + 'title' => 'WarnHtml', + 'content' => 'before &nobr; after
BodyWarn
', + ], + ]), + 'http_code' => 200, + 'location' => '', + 'content_disposition' => '', + ]); + + $url = 'http://8.8.8.8/nc3/warn.html'; + $page_id = 2009; + $target->runMigrationNc3Page($url, $page_id); + + $this->assertSame([$url], $target->requestedGetUrls()); + $this->assertNc3FrameSavedContains($page_id, 1, 'BodyWarn
'); + } + + /** + * 複数セクションを複数フレームとして保存できること + * + * @return void + */ + public function testMultipleNc3SectionsAreImportedAsMultipleFrames() + { + $this->fakeMigrationStorage(); + + $target = new TestableMigrationExportNc3PageTraitTarget(); + $target->queueGetResponse([ + 'body' => $this->buildNc3Html([ + [ + 'id' => 'frame-201', + 'class' => 'panel panel-info', + 'title' => 'Frame1', + 'content' => 'Body1
', + ], + [ + 'id' => 'frame-202', + 'class' => 'panel panel-success', + 'title' => 'Frame2', + 'content' => 'Body2
', + ], + ]), + 'http_code' => 200, + 'location' => '', + 'content_disposition' => '', + ]); + + $page_id = 2004; + $target->runMigrationNc3Page('http://8.8.8.8/nc3/multi.html', $page_id); + + $this->assertNc3FrameSavedContains($page_id, 1, 'Body1
'); + $this->assertNc3FrameSavedContains($page_id, 2, 'Body2
'); + $this->assertStringContainsString('frame_title = "Frame1"', Storage::get("migration/import/pages/{$page_id}/frame_0001.ini")); + $this->assertStringContainsString('frame_title = "Frame2"', Storage::get("migration/import/pages/{$page_id}/frame_0002.ini")); + } + + /** + * 画像ダウンロードを経由して画像参照をローカルファイル名へ置換すること + * + * @return void + */ + public function testImageDownloadIsImportedAndReferencedByLocalFileName() + { + $this->fakeMigrationStorage(); + + $target = new TestableMigrationExportNc3PageTraitTarget(); + $image_url = 'http://8.8.8.8/files/image.png'; + $target->queueGetResponse([ + 'body' => $this->buildNc3Html([ + [ + 'id' => 'frame-301', + 'class' => 'panel panel-primary', + 'title' => 'WithImage', + 'content' => 'KeepBody
', + ], + ]), + 'http_code' => 200, + 'location' => '', + 'content_disposition' => '', + ]); + $target->queueDownloadException(new RuntimeException('image download failed')); + + $page_id = 2007; + $target->runMigrationNc3Page('http://8.8.8.8/nc3/image-fail.html', $page_id); + + $this->assertSame([$image_url], $target->requestedDownloadUrls()); + $html = Storage::get("migration/import/pages/{$page_id}/frame_0001.html"); + $ini = Storage::get("migration/import/pages/{$page_id}/frame_0001.ini"); + + $this->assertStringContainsString('KeepBody', $html); + $this->assertStringContainsString($image_url, $html); + $this->assertStringNotContainsString('frame_0001_1.png', $html); + $this->assertStringContainsString('[image_names]', $ini); + $this->assertStringNotContainsString('frame_0001_1.png =', $ini); + } + + /** + * 添付ダウンロード失敗時は添付をスキップし、本文の保存は継続すること + * + * @return void + */ + public function testImportSkipsAttachmentWhenAttachmentDownloadFailsAndStillSavesBody() + { + $this->fakeMigrationStorage(); + + $target = new TestableMigrationExportNc3PageTraitTarget(); + $file_url = 'http://8.8.8.8/cabinet_files/download/999'; + $target->queueGetResponse([ + 'body' => $this->buildNc3Html([ + [ + 'id' => 'frame-601', + 'class' => 'panel panel-info', + 'title' => 'FileFail', + 'content' => 'KeepBody
', + ], + ]), + 'http_code' => 200, + 'location' => '', + 'content_disposition' => '', + ]); + $target->queueDownloadException(new RuntimeException('attachment download failed')); + + $page_id = 2008; + $target->runMigrationNc3Page('http://8.8.8.8/nc3/file-fail.html', $page_id); + + $this->assertSame([$file_url], $target->requestedDownloadUrls()); + $html = Storage::get("migration/import/pages/{$page_id}/frame_0001.html"); + $ini = Storage::get("migration/import/pages/{$page_id}/frame_0001.ini"); + + $this->assertStringContainsString('KeepBody', $html); + $this->assertStringContainsString($file_url, $html); + $this->assertStringNotContainsString('frame_0001_file_1.txt', $html); + $this->assertStringContainsString('[file_names]', $ini); + $this->assertStringNotContainsString('frame_0001_file_1.txt =', $ini); + } + + /** + * 移行用ストレージをfake化する + * + * @return void + */ + private function fakeMigrationStorage(): void + { + Storage::fake(config('filesystems.default', 'local')); + } + + /** + * 指定フレームのHTML/INIが保存されていないことを検証する + * + * @param int $page_id + * @param int $frame_index + * @return void + */ + private function assertFrameNotSaved(int $page_id, int $frame_index): void + { + $frame = sprintf('%04d', $frame_index); + Storage::assertMissing("migration/import/pages/{$page_id}/frame_{$frame}.html"); + Storage::assertMissing("migration/import/pages/{$page_id}/frame_{$frame}.ini"); + } + + /** + * 指定フレームのHTML/INIが保存され、HTMLに期待文字列を含むことを検証する + * + * @param int $page_id + * @param int $frame_index + * @param string $expected_fragment + * @return void + */ + private function assertNc3FrameSavedContains(int $page_id, int $frame_index, string $expected_fragment): void + { + $frame = sprintf('%04d', $frame_index); + Storage::assertExists("migration/import/pages/{$page_id}/frame_{$frame}.html"); + Storage::assertExists("migration/import/pages/{$page_id}/frame_{$frame}.ini"); + $this->assertStringContainsString($expected_fragment, Storage::get("migration/import/pages/{$page_id}/frame_{$frame}.html")); + } + + /** + * テスト用の最小NC3 HTMLを生成する + * + * @param array $sections + * @return string + */ + private function buildNc3Html(array $sections): string + { + $section_html = ''; + foreach ($sections as $section) { + $section_html .= '