From e3833f7984f7e08ea5b90f365b857901db0fe110 Mon Sep 17 00:00:00 2001 From: Gerard Oosterhof Date: Tue, 14 Jul 2026 09:51:48 +0200 Subject: [PATCH] ci: add pcov coverage --min gate + token-confidentiality regression pins MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a coverage floor to the published package's CI. Both ci.yml and release.yml gain the pcov driver (coverage: none -> pcov) and run a new `composer test:coverage` step (pest --coverage --min=98). Floor is the measured 98.1% — KendoReports.php:122 (the string-path file_get_contents attach) is the sole uncovered line, a parked M-1 Medic item; aspiration --min=100 is documented for ratchet-up once that branch is covered. Teeth are the pest --coverage --min CLI flag, not a phpunit.xml block (a silent no-op on PHPUnit 10+). Also pins the token-confidentiality invariant Sapper flagged as inspection- only: three tests assert the report:create Bearer token never appears in a surfaced ReportSubmissionException message (rejection + transport failure) or the error_log swallow path. A future ->throw()/->retry() that folded the request into the message would trip these before shipping a leak. No library source change. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01EmzU5UEBBVH4K691qiuxCh --- .github/workflows/ci.yml | 12 +++-- .github/workflows/release.yml | 8 ++-- CHANGELOG.md | 5 +++ composer.json | 1 + tests/Feature/KendoReportsTest.php | 71 ++++++++++++++++++++++++++++++ 5 files changed, 91 insertions(+), 6 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 75501a1..2960b1d 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -20,7 +20,7 @@ jobs: uses: shivammathur/setup-php@728c6c6b8cf02c2e48117716a91ee48313958a19 # v2 with: php-version: ${{ matrix.php }} - coverage: none + coverage: pcov tools: composer:v2 - name: Install dependencies @@ -35,8 +35,14 @@ jobs: - name: Static analysis run: composer phpstan - - name: Tests - run: composer test + # Coverage gate. Floor is the currently-measured 98% (KendoReports.php:122, + # the string-path file_get_contents attach branch, is the sole uncovered + # line — a parked M-1 Medic item). Aspiration: --min=100 once that branch + # is covered; ratchet the floor up then. Teeth are the pest --coverage --min + # CLI flag (a phpunit.xml block alone is a silent no-op on + # PHPUnit 10+). + - name: Tests (coverage gate) + run: composer test:coverage # Aggregate check tracked by the town-crier trial (TC-0069) — intended as the # single required status check (the check (8.4)/(8.5) matrix legs stop being diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 08e9cd5..b6f5d28 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -30,7 +30,7 @@ jobs: uses: shivammathur/setup-php@728c6c6b8cf02c2e48117716a91ee48313958a19 # v2 with: php-version: ${{ matrix.php }} - coverage: none + coverage: pcov tools: composer:v2 - name: Install dependencies @@ -45,8 +45,10 @@ jobs: - name: Static analysis run: composer phpstan - - name: Tests - run: composer test + # Coverage gate — mirrors ci.yml. Floor 98% (KendoReports.php:122 parked + # M-1 branch); aspiration --min=100. Teeth = pest --coverage --min CLI flag. + - name: Tests (coverage gate) + run: composer test:coverage release: needs: verify diff --git a/CHANGELOG.md b/CHANGELOG.md index 41b29b1..65f33fb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,11 @@ The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and ## [Unreleased] +### Added + +- CI coverage gate: `pest --coverage --min=98` with the `pcov` driver on the PHP 8.4/8.5 matrix (CI) and the release-verify job. Floor is the currently-measured 98% (the string-path `file_get_contents` attach branch in `KendoReports` is the sole uncovered line, a parked item); aspiration `--min=100`, ratcheted up once that branch is covered. +- Token-confidentiality regression tests: assert the `report:create` Bearer token never surfaces in a `ReportSubmissionException` message (rejection or transport failure) or in the `error_log` swallow path. + ## [0.1.0] — 2026-06-08 Inaugural public release — the kendo report-submission client library. diff --git a/composer.json b/composer.json index cfa9a45..31d83df 100644 --- a/composer.json +++ b/composer.json @@ -40,6 +40,7 @@ }, "scripts": { "test": "pest", + "test:coverage": "pest --coverage --min=98", "phpstan": "phpstan analyse", "format": "pint", "format:check": "pint --test" diff --git a/tests/Feature/KendoReportsTest.php b/tests/Feature/KendoReportsTest.php index 0923100..ce71278 100644 --- a/tests/Feature/KendoReportsTest.php +++ b/tests/Feature/KendoReportsTest.php @@ -158,6 +158,77 @@ function created(): array expect($client->submit('Broken', 'It broke'))->toBe(created()); }); +/** + * Token-confidentiality regression pin (Sapper durability caveat). + * + * The report:create Bearer token must never surface in a failure message. + * Today ReportSubmissionException carries only the HTTP status (rejected) or a + * transport reason (failed) — never the token. These pins lock that in: a future + * ->throw()/->retry() (which would fold the request — headers, Authorization — + * into the thrown/logged message) would fail here before it could leak the token + * into a caller-surfaced exception or the error_log swallow path. + */ +$secretToken = 'super-secret-report-create-token-value'; + +it('never leaks the report:create token in the exception surfaced on a rejection', function() use ($secretToken): void { + config()->set('report-tool.token', $secretToken); + Http::fake(['kendo.test/*' => Http::response(['message' => 'nope'], 422)]); + + try { + app(KendoReports::class)->submit('Broken', 'It broke'); + + throw new RuntimeException('Expected a ReportSubmissionException to be thrown.'); + } catch (ReportSubmissionException $e) { + expect($e->getMessage()) + ->toContain('422') + ->not->toContain($secretToken); + } +}); + +it('never leaks the report:create token in the exception surfaced on a transport failure', function() use ($secretToken): void { + config()->set('report-tool.token', $secretToken); + Http::fake(function(): void { + // A realistic transport failure — Guzzle's ConnectionException message + // carries the curl error, never the request's Authorization header. The + // pin proves the client never sources the token into the failure reason + // it surfaces (a future ->throw()/->retry() that folded the request into + // the message would trip this). + throw new ConnectionException('cURL error 28: Operation timed out after 5000 milliseconds'); + }); + + try { + app(KendoReports::class)->submit('Broken', 'It broke'); + + throw new RuntimeException('Expected a ReportSubmissionException to be thrown.'); + } catch (ReportSubmissionException $e) { + expect($e->getMessage())->not->toContain($secretToken); + } +}); + +it('never leaks the report:create token into the error_log swallow path', function() use ($secretToken): void { + config()->set('report-tool.token', $secretToken); + config()->set('report-tool.swallow', true); + Http::fake(['kendo.test/*' => Http::response(['message' => 'nope'], 422)]); + + $logFile = (string) tempnam(sys_get_temp_dir(), 'krt-errorlog-'); + $previous = ini_set('error_log', $logFile); + + try { + app(KendoReports::class)->submit('Broken', 'It broke'); + + $logged = (string) file_get_contents($logFile); + + expect($logged) + ->toContain('422') + ->not->toContain($secretToken); + } finally { + if ($previous !== false) { + ini_set('error_log', $previous); + } + @unlink($logFile); + } +}); + it('falls back to the default timeouts when the configured values are non-positive', function(): void { config()->set('report-tool.connect_timeout', 0); config()->set('report-tool.timeout', 'nonsense');