Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 3 additions & 3 deletions docs/known-risks.md
Original file line number Diff line number Diff line change
Expand Up @@ -25,15 +25,15 @@
| B1 | 再実行で Notion ページを重複作成しない | Preventive(URL 突合で create/update 分岐) | 2(`test_sync_action_creates_updates_and_deletes_pages` + `test_does_not_create_duplicate_when_page_already_exists_in_notion`) | 🟢 OK | — | 2026-06-22 |
| B2 | 1 件の Notion API エラーでバッチ全体が止まらない | Preventive(delete/add 両方とも項目単位 try/catch + 継続 + 失敗件数を error ログ。`addNewNotionPages` は `keyNotionPagesByUrl()` で URL 解析失敗も個別隔離。URL が無い Notion ページは削除対象から除外) | 3(`test_continues_syncing_when_one_item_fails` + `test_continues_deleting_when_one_delete_fails` + `test_does_not_delete_notion_page_without_url`) | 🟢 OK | — | 2026-06-22 |
| B3 | 未変更項目を毎回 re-PUSH せず API クォータを浪費しない | None(`synced_at`/状態フラグ無し) | 0 | 🔴 Missing | **記録のみ**。API 呼び出し回数が問題化したら `synced_at` を導入 | 2026-06-22 |
| B4 | Notion シークレットを例外/ログ/Discord に流出させない | Preventive(`SecretScrubber` で送出前に伏字化。C3 と一体) | 7(C3 のテストと共有) | 🟢 OK | — | 2026-06-22 |
| B4 | Notion シークレットを例外/ログ/Discord に流出させない | Preventive(`SecretScrubber` で送出前に伏字化。C3 と一体) | 8(C3 のテストと共有) | 🟢 OK | — | 2026-06-22 |

## C. シークレット / 資格情報の取扱い

| ID | 保護すべき挙動 / Expected Outcome | 制御 | テスト | Status | 是正条件 | 記録日 |
|----|----------------------------------|------|--------|--------|----------|--------|
| C1 | デバッグページが config/スタックトレースを露出しない | Preventive(`.env.example`/`.ci`/`.deploy` すべて `APP_DEBUG=false`、コード既定も false) | 2(`Http/ApiErrorResponseTest` + `Http/WebErrorResponseTest`) | 🟢 OK | — | 2026-06-22 |
| C2 | API 例外が生のトレースを返さない | Preventive(`APP_DEBUG=false` で汎用 JSON エラー) | 2(`Http/ApiErrorResponseTest`×2) | 🟢 OK | — | 2026-06-22 |
| C3 | 例外レポート(log/Discord)に機密値を混入させない | Preventive(`SecretScrubber`(伏字化対象は5文字以上の値のみ。"root"等の短い開発用パスワードでの誤爆を回避)+ `RedactSecretsProcessor`/tap + `ConvertDiscord`(親クラスの `addMessageStacktrace` を no-op 化し、未伏字化の生トレースでの上書きも防止) で送出前に伏字化) | 7(`SecretScrubberTest`×4 + `RedactSecretsProcessorTest` + `ConvertDiscordTest`×2) | 🟢 OK | — | 2026-06-22 |
| C3 | 例外レポート(log/Discord)に機密値を混入させない | Preventive(`SecretScrubber`(伏字化対象は5文字以上の値のみ。"root"等の短い開発用パスワードでの誤爆を回避)+ `RedactSecretsProcessor`/tap + `ConvertDiscord`(親クラスの `addMessageStacktrace` を no-op 化し、未伏字化の生トレースでの上書きも防止) で送出前に伏字化) | 8(`SecretScrubberTest`×5 + `RedactSecretsProcessorTest` + `ConvertDiscordTest`×2) | 🟢 OK | — | 2026-08-26(assurance-audit是正: `test_scrub_array_recurses_and_masks_throwable` が `json_encode` 経由でアサートしており例外オブジェクトの伏字化を検証できていなかった欠陥を修正。`scrubArray()` の戻り値を直接アサートする形に変更し、5文字境界値テストを追加) |
| C4 | 実シークレットを git に混入させない | Preventive(`.gitignore` で `.env` 除外、committed な `.env.*` は空値) | N/A | 🟢 OK | — | 2026-06-22 |

## D. 公開検索 / Feed / API
Expand All @@ -43,7 +43,7 @@
| D1 | raw_pages(生 HTML)を公開経路に露出しない | Preventive(検索/Feed で rawPage を eager-load しない、Page に html 列無し) | 2(`PageResourceTest::test_search_does_not_eager_load_raw_page_relation` + `SearchPage/FeedActionTest`) | 🟢 OK | — | 2026-06-22 |
| D2 | 非公開コンテンツを検索/Feed に出さない | N/A(Page に公開状態の概念が無い) | — | — | 対象外(将来 status 列を足す場合は再評価) | 2026-06-22 |
| D3 | API/Feed 応答に内部フィールドを含めない | Preventive(PageResource 許可リスト + `Page::toFeedItem()`) | 2(`PageResourceTest::test_resource_exposes_only_whitelisted_fields` + `test_feed_item_does_not_expose_internal_fields_or_raw_html`) | 🟢 OK | — | 2026-06-22 |
| D4 | 半分書かれた Page 行を検索に見せない(原子性) | Preventive(`UpdateOrCreatePageWithPaks` が Page 更新 + Pak 同期を `DB::transaction()` で原子化。3 Handler すべてこれを使用) | 2(`Extract/UpdateOrCreatePageWithPaksTest`) | 🟢 OK | — | 2026-06-22 |
| D4 | 半分書かれた Page 行を検索に見せない(原子性) | Preventive(`UpdateOrCreatePageWithPaks` が Page 更新 + Pak 同期を `DB::transaction()` で原子化。3 Handler すべてこれを使用) | 3(`Extract/UpdateOrCreatePageWithPaksTest`) | 🟢 OK | — | 2026-08-26(assurance-audit是正: 「DB::transaction が呼ばれたか」のモック確認と正常系コミットのみで、実際に途中失敗させてロールバックを確認するテストが無かった欠落を発見。`test_page_update_is_rolled_back_when_pak_sync_fails` を追加し、SyncPak/UpdateOrCreatePage が final のため実コードの契約違反経路で本物の失敗を起こしロールバックを検証) |

<!--
運用メモ:
Expand Down
33 changes: 33 additions & 0 deletions tests/Feature/Actions/Extract/UpdateOrCreatePageWithPaksTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,8 @@
use App\Actions\Extract\SyncPak;
use App\Actions\Extract\UpdateOrCreatePage;
use App\Actions\Extract\UpdateOrCreatePageWithPaks;
use App\Enums\PakSlug;
use App\Models\Page;
use App\Models\RawPage;
use Carbon\CarbonImmutable;
use Illuminate\Support\Facades\DB;
Expand Down Expand Up @@ -47,4 +49,35 @@ public function test_commits_page_and_paks_together_on_success(): void

$this->assertSame('新タイトル', $page->fresh()?->title);
}

public function test_page_update_is_rolled_back_when_pak_sync_fails(): void
{
$rawPage = RawPage::factory()->create();
$existingPage = Page::factory()->for($rawPage)->create(['title' => '旧タイトル']);

// SyncPak / UpdateOrCreatePage は final のため、上のテストのコメント同様
// Mockery で差し替えたフェイクを注入することができない。
// そこで実際の SyncPak::resolvePakIds() に契約(array<int,PakSlug>)に反する
// 値を渡し、実コード内で本物のエラー($pak->value の読み取り失敗。PHP 8 では
// ErrorException として捕捉される)を発生させる。これにより DB::transaction() の
// ロールバック(D4: 原子性)を、モックではなく本物のトランザクション・
// 本物の例外経路で検証できる。
/** @var array<int,PakSlug> $invalidPaks */
$invalidPaks = ['not-a-pak-slug'];

$updateOrCreatePageWithPaks = new UpdateOrCreatePageWithPaks(new UpdateOrCreatePage, new SyncPak);

$thrown = null;
try {
$updateOrCreatePageWithPaks($rawPage, '新タイトル', '新本文', CarbonImmutable::now(), $invalidPaks);
} catch (\Throwable $throwable) {
$thrown = $throwable;
}

$this->assertNotNull($thrown, 'SyncPak 内で例外が発生するはずだった');

// Page の title 更新は SyncPak 失敗前に実行済みだが、トランザクション全体が
// ロールバックされ、旧タイトルのままであること(rollback proven, not assumed)。
$this->assertSame('旧タイトル', $existingPage->fresh()?->title);
}
}
35 changes: 31 additions & 4 deletions tests/Feature/Actions/Logging/SecretScrubberTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -44,10 +44,37 @@ public function test_scrub_array_recurses_and_masks_throwable(): void
'exception' => new \RuntimeException('leaked ntn_supersecret here'),
]);

$encoded = json_encode($result);
$this->assertIsString($encoded);
$this->assertStringNotContainsString('ntn_supersecret', $encoded);
$this->assertStringNotContainsString('discord.com/api/webhooks/abc/xyz', $encoded);
// json_encode 経由だと例外オブジェクトは protected/private プロパティのため
// 中身が {} になり、伏字化の有無に関わらずテスト結果が変わらない(見せかけの保証)。
// walk() の戻り値を直接アサートする。
$this->assertIsString($result['url']);
$this->assertStringNotContainsString('discord.com/api/webhooks/abc/xyz', $result['url']);
$this->assertStringContainsString('[REDACTED]', $result['url']);

$this->assertIsArray($result['nested']);
$this->assertIsString($result['nested']['token']);
$this->assertStringNotContainsString('ntn_supersecret', $result['nested']['token']);
$this->assertStringContainsString('[REDACTED]', $result['nested']['token']);

// Throwable -> string への変換は walk() のドキュメント化された契約(SecretScrubber.php 参照)。
$this->assertIsString($result['exception']);
$this->assertStringNotContainsString('ntn_supersecret', $result['exception']);
$this->assertStringContainsString('[REDACTED]', $result['exception']);
}

public function test_scrub_masks_secret_at_minimum_length_boundary(): void
{
// secrets() の境界値: mb_strlen >= 5 が伏字化対象の下限(SecretScrubber.php 100行目付近)。
// 4文字以下は対象外であることは既存の test_scrub_does_not_mangle_common_words_when_secret_is_short
// で確認済み。ここではちょうど5文字の秘密情報が実際に伏字化されることを確認する。
Config::set('database.connections.mysql.password', '12345');

$secretScrubber = new SecretScrubber;

$result = $secretScrubber->scrub('pass: 12345 end');

$this->assertStringNotContainsString('12345', $result);
$this->assertStringContainsString('[REDACTED]', $result);
}

public function test_scrub_does_not_mangle_common_words_when_secret_is_short(): void
Expand Down
Loading