diff --git a/.github/workflows/dockerhub-credential-check.yml b/.github/workflows/dockerhub-credential-check.yml new file mode 100644 index 00000000..a0287c7a --- /dev/null +++ b/.github/workflows/dockerhub-credential-check.yml @@ -0,0 +1,52 @@ +name: Docker Hub Credential Check + +# Manual workflow that proves DOCKERHUB_USERNAME / DOCKERHUB_TOKEN are valid +# for the readme-push API path before triggering an expensive build. Issue #714 +# rotated the secret because the readme push was returning Forbidden; this lets +# whoever rotates next confirm the new value works in seconds, without pushing +# an image. +# +# Validates two paths: registry login (used by docker/login-action in the +# build workflows) and the Docker Hub API (used by peter-evans/dockerhub-description +# to PATCH the repo description). A token can pass the first and fail the +# second, which is exactly the failure mode that prompted #714. +# +# The API check writes the current description back to itself via a no-op PATCH +# so it actually exercises the write/delete scope peter-evans uses; a read-only +# token would 200 on a GET but 403 here. Side effect: bumps Docker Hub's +# last-modified timestamp on the repo (the same side effect the real readme +# push already produces every time it runs, so nothing novel). + +on: + workflow_dispatch: + +permissions: {} + +jobs: + check: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v6 + + - name: Validate registry login + uses: docker/login-action@v4 + with: + username: ${{ secrets.DOCKERHUB_USERNAME }} + password: ${{ secrets.DOCKERHUB_TOKEN }} + + - name: Setup PHP + uses: shivammathur/setup-php@v2 + with: + php-version: '8.5' + + - name: Install Task + uses: arduino/setup-task@v2 + with: + version: 3.x + + - name: Validate Docker Hub API auth + env: + DOCKERHUB_USERNAME: ${{ secrets.DOCKERHUB_USERNAME }} + DOCKERHUB_TOKEN: ${{ secrets.DOCKERHUB_TOKEN }} + working-directory: tools/release + run: task ci:check-dockerhub-credential diff --git a/tools/release/Taskfile.yml b/tools/release/Taskfile.yml index ccb19f89..775df811 100644 --- a/tools/release/Taskfile.yml +++ b/tools/release/Taskfile.yml @@ -153,6 +153,14 @@ tasks: php bin/lint-versions.php --repo={{shellQuote (default "../.." .ROTATE_REPO)}} + ci:check-dockerhub-credential: + desc: Smoke-test DOCKERHUB_USERNAME / DOCKERHUB_TOKEN against the readme-push API path + deps: [setup] + cmds: + - >- + php bin/check-dockerhub-credential.php + {{if .REPOSITORY}}--repository={{shellQuote .REPOSITORY}}{{end}} + release:verify-tag: desc: Verify a release tag is annotated and well-formed per #664 spec requires: diff --git a/tools/release/bin/check-dockerhub-credential.php b/tools/release/bin/check-dockerhub-credential.php new file mode 100644 index 00000000..ef4243f0 --- /dev/null +++ b/tools/release/bin/check-dockerhub-credential.php @@ -0,0 +1,61 @@ +#!/usr/bin/env php + + * @copyright Copyright (c) 2026 OpenCoreEMR Inc. + * @license https://github.com/openemr/openemr-devops/blob/master/LICENSE GNU General Public License 3 + */ + +declare(strict_types=1); + +require dirname(__DIR__) . '/vendor/autoload.php'; + +use OpenEMR\Release\DockerHubCredentialChecker; +use Symfony\Component\Console\Input\InputInterface; +use Symfony\Component\Console\Input\InputOption; +use Symfony\Component\Console\Output\OutputInterface; +use Symfony\Component\Console\SingleCommandApplication; + +(new SingleCommandApplication()) + ->setName('check-dockerhub-credential') + ->setDescription('Validate DOCKERHUB_USERNAME / DOCKERHUB_TOKEN for the readme-push API path') + ->addOption( + 'repository', + null, + InputOption::VALUE_REQUIRED, + 'Docker Hub repository (owner/name)', + 'openemr/openemr', + ) + ->setCode(function (InputInterface $input, OutputInterface $output): int { + $username = getenv('DOCKERHUB_USERNAME'); + $token = getenv('DOCKERHUB_TOKEN'); + if (!is_string($username) || $username === '') { + $output->writeln('::error::DOCKERHUB_USERNAME env var is required'); + return 1; + } + if (!is_string($token) || $token === '') { + $output->writeln('::error::DOCKERHUB_TOKEN env var is required'); + return 1; + } + /** @var string $repository */ + $repository = $input->getOption('repository'); + if (preg_match('#^[A-Za-z0-9][A-Za-z0-9._-]*/[A-Za-z0-9._-]+$#', $repository) !== 1) { + $output->writeln('::error::--repository must match owner/name (alphanumeric, dot, underscore, dash).'); + return 1; + } + + $result = (new DockerHubCredentialChecker())->check($username, $token, $repository); + $output->writeln($result->toGithubActionsLine()); + return $result->isOk() ? 0 : 1; + }) + ->run(); diff --git a/tools/release/composer.json b/tools/release/composer.json index f0e743dc..ee2618d1 100644 --- a/tools/release/composer.json +++ b/tools/release/composer.json @@ -5,6 +5,7 @@ "license": "GPL-2.0-or-later", "require": { "php": "^8.5", + "ext-curl": "*", "ext-mbstring": "*", "nikic/php-parser": "^5.0", "symfony/console": "^7.0", diff --git a/tools/release/composer.lock b/tools/release/composer.lock index 884dd3be..f74a7f8e 100644 --- a/tools/release/composer.lock +++ b/tools/release/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "eeeeeebec849bc51c28929c3abd7fdd4", + "content-hash": "bd0efe5b5b3aae4391277ea8d769414e", "packages": [ { "name": "nikic/php-parser", @@ -3943,6 +3943,7 @@ "prefer-lowest": false, "platform": { "php": "^8.5", + "ext-curl": "*", "ext-mbstring": "*" }, "platform-dev": {}, diff --git a/tools/release/src/DockerHubCredentialCheckResult.php b/tools/release/src/DockerHubCredentialCheckResult.php new file mode 100644 index 00000000..d6e15f5a --- /dev/null +++ b/tools/release/src/DockerHubCredentialCheckResult.php @@ -0,0 +1,162 @@ + + * @copyright Copyright (c) 2026 OpenCoreEMR Inc. + * @license https://github.com/openemr/openemr-devops/blob/master/LICENSE GNU General Public License 3 + */ + +declare(strict_types=1); + +namespace OpenEMR\Release; + +final readonly class DockerHubCredentialCheckResult +{ + public DockerHubCredentialCheckStatus $status; + public string $repository; + public ?int $httpStatus; + public ?string $detail; + + public function __construct( + DockerHubCredentialCheckStatus $status, + string $repository, + ?int $httpStatus = null, + ?string $detail = null, + ) { + // Defensively scrub CR/LF from caller-controlled strings before they + // ever get formatted into a `::error::` / `::notice::` line. The + // workflow-command syntax is line-based; an embedded newline could + // inject a second command. Belt-and-braces — the bin layer also + // validates repository against an owner/name pattern up front. + $this->status = $status; + $this->repository = $this->scrubLineBreaks($repository); + $this->httpStatus = $httpStatus; + $this->detail = $detail !== null ? $this->scrubLineBreaks($detail) : null; + } + + private function scrubLineBreaks(string $value): string + { + return strtr($value, ["\r" => ' ', "\n" => ' ']); + } + + /** + * Map raw HTTP statuses from Docker Hub's login + repository read + + * repository write probes to a result. Pure: no network. Tested directly. + * + * - $loginStatus is the POST /v2/users/login/ HTTP status (null if the + * request itself failed at the transport layer) + * - $jwt is the token extracted from the login response (null if the + * response was unparseable JSON or missing the token field) + * - $readStatus is the GET /v2/repositories// status (null if the + * step was not reached) + * - $descriptionsParsed is whether the GET response body was usable JSON + * with the expected fields (null if read step not reached) + * - $writeStatus is the no-op PATCH /v2/repositories// status + * (null if the step was not reached) + */ + public static function interpret( + string $repository, + ?int $loginStatus, + ?string $jwt, + ?int $readStatus, + ?bool $descriptionsParsed, + ?int $writeStatus, + ): self { + if ($loginStatus === null) { + return new self(DockerHubCredentialCheckStatus::NETWORK_ERROR, $repository); + } + if (in_array($jwt, [null, '', 'null'], true)) { + return self::fromAuthFailure($repository, $loginStatus); + } + if ($readStatus === null) { + return new self(DockerHubCredentialCheckStatus::NETWORK_ERROR, $repository); + } + if ($readStatus !== 200 || $descriptionsParsed !== true) { + return self::fromAccessFailure($repository, $readStatus); + } + if ($writeStatus === null) { + return new self(DockerHubCredentialCheckStatus::NETWORK_ERROR, $repository); + } + return self::fromWriteStatus($repository, $writeStatus); + } + + public function isOk(): bool + { + return $this->status === DockerHubCredentialCheckStatus::OK; + } + + /** + * Format as a single GitHub-Actions workflow command line + * (`::error::…` or `::notice::…`). + */ + public function toGithubActionsLine(): string + { + return match ($this->status) { + DockerHubCredentialCheckStatus::OK => sprintf( + '::notice::Credential is valid for %s (read + no-op write confirmed).', + $this->repository, + ), + DockerHubCredentialCheckStatus::INVALID_CREDENTIAL => + '::error::Login failed (HTTP ' . $this->httpStatusOrUnknown() + . ') — DOCKERHUB_USERNAME / DOCKERHUB_TOKEN appear invalid.', + DockerHubCredentialCheckStatus::INSUFFICIENT_SCOPE => sprintf( + '::error::Login succeeded but the token lacks required scope on %s (HTTP %s). ' + . 'Verify R/W/D scope on this repository.', + $this->repository, + $this->httpStatusOrUnknown(), + ), + DockerHubCredentialCheckStatus::UNEXPECTED_RESPONSE => sprintf( + '::error::Unexpected response from Docker Hub API for %s (HTTP %s). %s', + $this->repository, + $this->httpStatusOrUnknown(), + $this->detail ?? 'Re-run, check status.docker.com, then re-evaluate.', + ), + DockerHubCredentialCheckStatus::NETWORK_ERROR => sprintf( + '::error::Could not reach Docker Hub API for %s. %s', + $this->repository, + $this->detail ?? 'Re-run, check status.docker.com, then re-evaluate.', + ), + }; + } + + private static function fromAuthFailure(string $repository, int $loginStatus): self + { + return match (true) { + in_array($loginStatus, [401, 403], true) => + new self(DockerHubCredentialCheckStatus::INVALID_CREDENTIAL, $repository, $loginStatus), + default => + new self(DockerHubCredentialCheckStatus::UNEXPECTED_RESPONSE, $repository, $loginStatus), + }; + } + + private static function fromAccessFailure(string $repository, int $readStatus): self + { + // 401 = JWT not accepted by this endpoint (rotate the credential). + // 403 = JWT recognized but lacks scope on this repo (grant scope). + // Distinct remediations, distinct statuses. + return match ($readStatus) { + 401 => new self(DockerHubCredentialCheckStatus::INVALID_CREDENTIAL, $repository, 401), + 403 => new self(DockerHubCredentialCheckStatus::INSUFFICIENT_SCOPE, $repository, 403), + default => new self(DockerHubCredentialCheckStatus::UNEXPECTED_RESPONSE, $repository, $readStatus), + }; + } + + private static function fromWriteStatus(string $repository, int $writeStatus): self + { + return match ($writeStatus) { + 200 => new self(DockerHubCredentialCheckStatus::OK, $repository, 200), + 401 => new self(DockerHubCredentialCheckStatus::INVALID_CREDENTIAL, $repository, 401), + 403 => new self(DockerHubCredentialCheckStatus::INSUFFICIENT_SCOPE, $repository, 403), + default => new self(DockerHubCredentialCheckStatus::UNEXPECTED_RESPONSE, $repository, $writeStatus), + }; + } + + private function httpStatusOrUnknown(): string + { + return $this->httpStatus !== null ? (string) $this->httpStatus : '(unknown)'; + } +} diff --git a/tools/release/src/DockerHubCredentialCheckStatus.php b/tools/release/src/DockerHubCredentialCheckStatus.php new file mode 100644 index 00000000..64da3e70 --- /dev/null +++ b/tools/release/src/DockerHubCredentialCheckStatus.php @@ -0,0 +1,22 @@ + + * @copyright Copyright (c) 2026 OpenCoreEMR Inc. + * @license https://github.com/openemr/openemr-devops/blob/master/LICENSE GNU General Public License 3 + */ + +declare(strict_types=1); + +namespace OpenEMR\Release; + +enum DockerHubCredentialCheckStatus: string +{ + case OK = 'ok'; + case INVALID_CREDENTIAL = 'invalid_credential'; + case INSUFFICIENT_SCOPE = 'insufficient_scope'; + case UNEXPECTED_RESPONSE = 'unexpected_response'; + case NETWORK_ERROR = 'network_error'; +} diff --git a/tools/release/src/DockerHubCredentialChecker.php b/tools/release/src/DockerHubCredentialChecker.php new file mode 100644 index 00000000..6c0a183a --- /dev/null +++ b/tools/release/src/DockerHubCredentialChecker.php @@ -0,0 +1,213 @@ +/). + * + * Distinguishes "bad credential" from "credential lacks scope on this repo" — + * a token can pass docker login (registry auth) and still 403 on the API + * path, which is exactly the failure mode openemr/openemr-devops#714 had to + * recover from. + * + * Verifies write scope by reading the current repo description fields and + * issuing a no-op PATCH that writes the same values back. That exercises the + * exact endpoint peter-evans/dockerhub-description uses; a read-only token + * passes the GET but 403s on the PATCH. Side effect: bumps Docker Hub's + * last-modified timestamp on the repo. The actual readme push does the same + * thing every time it runs, so this is not a novel side effect. + * + * Internally tolerant of transport errors and unparseable responses (HTML + * error pages, partial JSON, etc.) — both surface as a structured + * DockerHubCredentialCheckResult so the workflow always emits exactly one + * `::error::` or `::notice::` line. + * + * @package openemr-devops + * @link https://www.open-emr.org + * @author Michael A. Smith + * @copyright Copyright (c) 2026 OpenCoreEMR Inc. + * @license https://github.com/openemr/openemr-devops/blob/master/LICENSE GNU General Public License 3 + */ + +declare(strict_types=1); + +namespace OpenEMR\Release; + +final readonly class DockerHubCredentialChecker +{ + private const DEFAULT_API_BASE = 'https://hub.docker.com/v2'; + + public function __construct( + private string $apiBase = self::DEFAULT_API_BASE, + ) { + } + + public function check(string $username, string $token, string $repository): DockerHubCredentialCheckResult + { + try { + [$loginStatus, $jwt] = $this->mintJwt($username, $token); + } catch (\RuntimeException $e) { + return new DockerHubCredentialCheckResult( + DockerHubCredentialCheckStatus::NETWORK_ERROR, + $repository, + detail: $e->getMessage(), + ); + } + if ($loginStatus !== 200 || $jwt === null) { + return DockerHubCredentialCheckResult::interpret( + $repository, + $loginStatus, + $jwt, + null, + null, + null, + ); + } + + try { + [$readStatus, $descriptions] = $this->fetchDescriptions($jwt, $repository); + } catch (\RuntimeException $e) { + return new DockerHubCredentialCheckResult( + DockerHubCredentialCheckStatus::NETWORK_ERROR, + $repository, + detail: $e->getMessage(), + ); + } + if ($readStatus !== 200 || $descriptions === null) { + return DockerHubCredentialCheckResult::interpret( + $repository, + $loginStatus, + $jwt, + $readStatus, + $descriptions !== null, + null, + ); + } + + try { + $writeStatus = $this->probeWrite($jwt, $repository, $descriptions); + } catch (\RuntimeException $e) { + return new DockerHubCredentialCheckResult( + DockerHubCredentialCheckStatus::NETWORK_ERROR, + $repository, + detail: $e->getMessage(), + ); + } + + return DockerHubCredentialCheckResult::interpret( + $repository, + $loginStatus, + $jwt, + $readStatus, + true, + $writeStatus, + ); + } + + /** + * @return array{int, ?string} + */ + private function mintJwt(string $username, string $token): array + { + try { + $body = json_encode(['username' => $username, 'password' => $token], JSON_THROW_ON_ERROR); + } catch (\JsonException $e) { + // Encoding our own input shouldn't fail; treat as transport-level. + throw new \RuntimeException('failed to encode login payload: ' . $e->getMessage(), 0, $e); + } + [$status, $responseBody] = $this->httpRequest('POST', $this->apiBase . '/users/login/', [ + 'Content-Type: application/json', + ], $body); + + if ($status !== 200) { + return [$status, null]; + } + try { + $decoded = json_decode($responseBody, true, flags: JSON_THROW_ON_ERROR); + } catch (\JsonException) { + return [$status, null]; + } + if (!is_array($decoded) || !isset($decoded['token']) || !is_string($decoded['token'])) { + return [$status, null]; + } + return [$status, $decoded['token']]; + } + + /** + * @return array{int, ?array{description: string, full_description: string}} + */ + private function fetchDescriptions(string $jwt, string $repository): array + { + [$status, $body] = $this->httpRequest('GET', $this->apiBase . '/repositories/' . $repository . '/', [ + 'Authorization: JWT ' . $jwt, + ]); + if ($status !== 200) { + return [$status, null]; + } + try { + $decoded = json_decode($body, true, flags: JSON_THROW_ON_ERROR); + } catch (\JsonException) { + return [$status, null]; + } + if (!is_array($decoded)) { + return [$status, null]; + } + // Strict: if either field is missing or not a string, treat as a parse + // failure rather than substituting empty strings. Substituting and then + // PATCHing would clear the live description if the API shape ever + // changed (e.g. description becomes nullable or gets renamed). Better + // to surface UNEXPECTED_RESPONSE than to write garbage back. + $description = $decoded['description'] ?? null; + $fullDescription = $decoded['full_description'] ?? null; + if (!is_string($description) || !is_string($fullDescription)) { + return [$status, null]; + } + return [$status, ['description' => $description, 'full_description' => $fullDescription]]; + } + + /** + * @param array{description: string, full_description: string} $descriptions + */ + private function probeWrite(string $jwt, string $repository, array $descriptions): int + { + try { + $body = json_encode($descriptions, JSON_THROW_ON_ERROR); + } catch (\JsonException $e) { + throw new \RuntimeException('failed to encode patch payload: ' . $e->getMessage(), 0, $e); + } + [$status] = $this->httpRequest('PATCH', $this->apiBase . '/repositories/' . $repository . '/', [ + 'Authorization: JWT ' . $jwt, + 'Content-Type: application/json', + ], $body); + return $status; + } + + /** + * @param non-empty-string $method + * @param list $headers + * @return array{int, string} + */ + private function httpRequest(string $method, string $url, array $headers, ?string $body = null): array + { + $ch = curl_init($url); + if ($ch === false) { + throw new \RuntimeException('curl_init failed'); + } + curl_setopt_array($ch, [ + CURLOPT_CUSTOMREQUEST => $method, + CURLOPT_RETURNTRANSFER => true, + CURLOPT_HTTPHEADER => $headers, + CURLOPT_TIMEOUT => 10, + ]); + if ($body !== null) { + curl_setopt($ch, CURLOPT_POSTFIELDS, $body); + } + $response = curl_exec($ch); + if ($response === false) { + $error = curl_error($ch); + throw new \RuntimeException("curl error for {$method} {$url}: {$error}"); + } + /** @var int $status */ + $status = curl_getinfo($ch, CURLINFO_RESPONSE_CODE); + return [$status, is_string($response) ? $response : '']; + } +} diff --git a/tools/release/tests/DockerHubCredentialCheckResultTest.php b/tools/release/tests/DockerHubCredentialCheckResultTest.php new file mode 100644 index 00000000..eeabdfd5 --- /dev/null +++ b/tools/release/tests/DockerHubCredentialCheckResultTest.php @@ -0,0 +1,201 @@ + + * @copyright Copyright (c) 2026 OpenCoreEMR Inc. + * @license https://github.com/openemr/openemr-devops/blob/master/LICENSE GNU General Public License 3 + */ + +declare(strict_types=1); + +namespace OpenEMR\Release\Tests; + +use OpenEMR\Release\DockerHubCredentialCheckResult; +use OpenEMR\Release\DockerHubCredentialCheckStatus; +use PHPUnit\Framework\TestCase; + +final class DockerHubCredentialCheckResultTest extends TestCase +{ + private const REPO = 'openemr/openemr'; + + public function testNetworkErrorWhenLoginStatusUnknown(): void + { + $result = DockerHubCredentialCheckResult::interpret(self::REPO, null, null, null, null, null); + + self::assertSame(DockerHubCredentialCheckStatus::NETWORK_ERROR, $result->status); + self::assertStringContainsString('Could not reach Docker Hub', $result->toGithubActionsLine()); + } + + public function testInvalidCredentialOnLogin401(): void + { + $result = DockerHubCredentialCheckResult::interpret(self::REPO, 401, null, null, null, null); + + self::assertSame(DockerHubCredentialCheckStatus::INVALID_CREDENTIAL, $result->status); + self::assertSame(401, $result->httpStatus); + self::assertStringContainsString('HTTP 401', $result->toGithubActionsLine()); + } + + public function testInvalidCredentialOnLogin403(): void + { + $result = DockerHubCredentialCheckResult::interpret(self::REPO, 403, null, null, null, null); + + self::assertSame(DockerHubCredentialCheckStatus::INVALID_CREDENTIAL, $result->status); + } + + public function testUnexpectedResponseOnLogin500(): void + { + $result = DockerHubCredentialCheckResult::interpret(self::REPO, 500, null, null, null, null); + + self::assertSame(DockerHubCredentialCheckStatus::UNEXPECTED_RESPONSE, $result->status); + self::assertSame(500, $result->httpStatus); + self::assertStringContainsString('HTTP 500', $result->toGithubActionsLine()); + } + + public function testUnexpectedResponseWhenLogin200ButNoJwt(): void + { + // Server returned 200 but the body wasn't parseable JSON or lacked the token field. + $result = DockerHubCredentialCheckResult::interpret(self::REPO, 200, null, null, null, null); + + self::assertSame(DockerHubCredentialCheckStatus::UNEXPECTED_RESPONSE, $result->status); + } + + public function testRejectsLiteralStringNullJwt(): void + { + $result = DockerHubCredentialCheckResult::interpret(self::REPO, 200, 'null', null, null, null); + + self::assertSame(DockerHubCredentialCheckStatus::UNEXPECTED_RESPONSE, $result->status); + } + + public function testNetworkErrorWhenReadStatusUnknown(): void + { + $result = DockerHubCredentialCheckResult::interpret(self::REPO, 200, 'jwt', null, null, null); + + self::assertSame(DockerHubCredentialCheckStatus::NETWORK_ERROR, $result->status); + } + + public function testInsufficientScopeOnRead403(): void + { + $result = DockerHubCredentialCheckResult::interpret(self::REPO, 200, 'jwt', 403, false, null); + + self::assertSame(DockerHubCredentialCheckStatus::INSUFFICIENT_SCOPE, $result->status); + self::assertSame(403, $result->httpStatus); + } + + public function testInvalidCredentialOnRead401(): void + { + // 401 from the repo endpoint after a successful login means the JWT is + // not accepted here — distinct from "JWT recognized but lacks scope". + $result = DockerHubCredentialCheckResult::interpret(self::REPO, 200, 'jwt', 401, false, null); + + self::assertSame(DockerHubCredentialCheckStatus::INVALID_CREDENTIAL, $result->status); + self::assertSame(401, $result->httpStatus); + } + + public function testUnexpectedResponseOnRead404(): void + { + $result = DockerHubCredentialCheckResult::interpret(self::REPO, 200, 'jwt', 404, false, null); + + self::assertSame(DockerHubCredentialCheckStatus::UNEXPECTED_RESPONSE, $result->status); + self::assertSame(404, $result->httpStatus); + } + + public function testUnexpectedResponseOnRead500(): void + { + $result = DockerHubCredentialCheckResult::interpret(self::REPO, 200, 'jwt', 500, false, null); + + self::assertSame(DockerHubCredentialCheckStatus::UNEXPECTED_RESPONSE, $result->status); + } + + public function testUnexpectedResponseWhenRead200ButUnparseable(): void + { + $result = DockerHubCredentialCheckResult::interpret(self::REPO, 200, 'jwt', 200, false, null); + + self::assertSame(DockerHubCredentialCheckStatus::UNEXPECTED_RESPONSE, $result->status); + } + + public function testNetworkErrorWhenWriteStatusUnknown(): void + { + $result = DockerHubCredentialCheckResult::interpret(self::REPO, 200, 'jwt', 200, true, null); + + self::assertSame(DockerHubCredentialCheckStatus::NETWORK_ERROR, $result->status); + } + + public function testInsufficientScopeWhenReadOkButWrite403(): void + { + $result = DockerHubCredentialCheckResult::interpret(self::REPO, 200, 'jwt', 200, true, 403); + + self::assertSame(DockerHubCredentialCheckStatus::INSUFFICIENT_SCOPE, $result->status); + self::assertSame(403, $result->httpStatus, 'httpStatus reflects the failing probe (write)'); + self::assertStringContainsString('R/W/D scope', $result->toGithubActionsLine()); + } + + public function testInvalidCredentialWhenReadOkButWrite401(): void + { + $result = DockerHubCredentialCheckResult::interpret(self::REPO, 200, 'jwt', 200, true, 401); + + self::assertSame(DockerHubCredentialCheckStatus::INVALID_CREDENTIAL, $result->status); + self::assertSame(401, $result->httpStatus); + } + + public function testOkWhenAllStepsSucceed(): void + { + $result = DockerHubCredentialCheckResult::interpret(self::REPO, 200, 'jwt', 200, true, 200); + + self::assertSame(DockerHubCredentialCheckStatus::OK, $result->status); + self::assertTrue($result->isOk()); + self::assertSame(200, $result->httpStatus); + self::assertStringContainsString('::notice::Credential is valid', $result->toGithubActionsLine()); + self::assertStringContainsString('read + no-op write confirmed', $result->toGithubActionsLine()); + } + + public function testUnexpectedResponseOnWrite500(): void + { + $result = DockerHubCredentialCheckResult::interpret(self::REPO, 200, 'jwt', 200, true, 500); + + self::assertSame(DockerHubCredentialCheckStatus::UNEXPECTED_RESPONSE, $result->status); + self::assertSame(500, $result->httpStatus); + } + + public function testGithubActionsLineIsSingleLine(): void + { + foreach (DockerHubCredentialCheckStatus::cases() as $status) { + $result = new DockerHubCredentialCheckResult($status, self::REPO, 200, 'detail'); + self::assertStringNotContainsString("\n", $result->toGithubActionsLine(), $status->value); + } + } + + public function testScrubsLineBreaksFromRepository(): void + { + $result = new DockerHubCredentialCheckResult( + DockerHubCredentialCheckStatus::OK, + "openemr/openemr\n::error::pwned", + 200, + ); + + // GitHub Actions workflow commands are recognized only at the start + // of a line. Stripping CR/LF means a malicious payload can still + // appear inline as text but cannot start a new command line. + self::assertStringNotContainsString("\n", $result->repository); + self::assertStringNotContainsString("\r", $result->repository); + self::assertStringNotContainsString("\n", $result->toGithubActionsLine()); + self::assertStringNotContainsString("\r", $result->toGithubActionsLine()); + } + + public function testScrubsCarriageReturnsAndNewlinesFromDetail(): void + { + $result = new DockerHubCredentialCheckResult( + DockerHubCredentialCheckStatus::NETWORK_ERROR, + self::REPO, + null, + "curl error\r\n::warning::injected", + ); + + self::assertNotNull($result->detail); + self::assertStringNotContainsString("\n", $result->detail); + self::assertStringNotContainsString("\r", $result->detail); + self::assertStringNotContainsString("\n", $result->toGithubActionsLine()); + self::assertStringNotContainsString("\r", $result->toGithubActionsLine()); + } +}