Skip to content

Commit 0e936f6

Browse files
[BUGFIX] Quarantine the domain the request was blocked on (#307)
A quarantined rendering recorded the host of the **clone url**, while the check that blocked it keys on the host of the **composer.json url**. For GitHub those never match — `github.com` versus `raw.githubusercontent.com`. That is not cosmetic: approving an entry allowlists exactly the recorded domain and replays every entry sharing it. So an admin approving a GitHub repository creates a `KnownRepositoryDomain` for a domain the fetch check never consults, while the replayed events are checked against the real host and land in quarantine again. The approval silently does nothing. This records the host the check actually rejected. `updateLastHit()` had the same mismatch and could never find the row it meant to touch. **Needs a migration:** the feature shipped in 7.2.0, so existing rows carry the wrong host and approving one of them reproduces the bug. The migration recomputes the domain from the push event each row already stores. `t3g:test` (162 tests), `t3g:phpstan` and `t3g:cgl` pass. Found while working on #305, independent of it. <details> <summary>Details — migration behaviour, the replay fix, tests, merge notes</summary> ### Migration The domain is recomputed from `serialized_push_event`, which contains `urlToComposerFile`. Rows whose payload cannot be decoded are skipped rather than failing the migration; rows already correct (Bitbucket Cloud, most GitLab setups) are left alone. The checksum hashes only the serialized push event and does not include the domain, so deduplication is unaffected. It is irreversible — the old value came from a different url and cannot be reconstructed. I ran it against a SQLite database seeded with a GitHub row, a Bitbucket row and a row with an undecodable payload: only the GitHub row changed, from `github.com` to `raw.githubusercontent.com`. ### Replay loop made robust Approving a domain replays every entry it holds. Until now a single entry that cannot be rendered — an irrelevant branch name is the likely case — threw out of the loop, gave the admin a 500, aborted the remaining entries and left the queue half-processed. This was invisible before, because the loop never got that far. Such an entry is now skipped and the admin is told how many were dropped. Both approval paths are covered. ### Tests `RenderDocumentationServiceTest` pins that the host handed to `quarantine()` and to `updateLastHit()` is the composer host, using a GitHub-shaped push event where the two differ. I verified it discriminates by reverting each production line separately — the test fails each time. The earlier version of this PR only asserted a setter passthrough, which stayed green when the real bug was restored; that gap is what this test closes. ### Merge notes #306 touches `RenderDocumentationService` in the same area. All three related PRs merge onto `develop` cleanly in any order — verified by performing the merges, with an identical resulting tree and a green combined suite. </details> Signed-off-by: Sebastian Mendel <github@sebastianmendel.de> Co-authored-by: Andreas Kienast <andreas.kienast@typo3.com>
1 parent 0d3cf9e commit 0e936f6

7 files changed

Lines changed: 223 additions & 8 deletions
Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace DoctrineMigrations;
6+
7+
use App\Utility\RepositoryUrlUtility;
8+
use Doctrine\DBAL\Schema\Schema;
9+
use Doctrine\Migrations\AbstractMigration;
10+
11+
/**
12+
* Quarantined entries recorded the host of the clone url, while the check that
13+
* blocked them keys on the host of the composer.json url. Recompute the domain
14+
* of the existing rows from the push event they carry, so approving them
15+
* allowlists the host that is actually consulted.
16+
*/
17+
final class Version20260804120000 extends AbstractMigration
18+
{
19+
public function getDescription(): string
20+
{
21+
return 'Set the quarantined domain to the host of the composer.json url';
22+
}
23+
24+
public function up(Schema $schema): void
25+
{
26+
$rows = $this->connection->fetchAllAssociative('SELECT id, domain, serialized_push_event FROM documentation_quarantine');
27+
foreach ($rows as $row) {
28+
try {
29+
$pushEvent = json_decode((string) $row['serialized_push_event'], true, 512, JSON_THROW_ON_ERROR);
30+
} catch (\JsonException) {
31+
continue;
32+
}
33+
$urlToComposerFile = (string) ($pushEvent['urlToComposerFile'] ?? '');
34+
if ('' === $urlToComposerFile) {
35+
continue;
36+
}
37+
$domain = RepositoryUrlUtility::getNormalizedDomain($urlToComposerFile);
38+
if ('' === $domain || $domain === $row['domain']) {
39+
continue;
40+
}
41+
$this->connection->update('documentation_quarantine', ['domain' => $domain], ['id' => $row['id']]);
42+
}
43+
}
44+
45+
public function down(Schema $schema): void
46+
{
47+
$this->throwIrreversibleMigrationException('The previously stored domain can not be restored, it was derived from the clone url.');
48+
}
49+
50+
public function isTransactional(): bool
51+
{
52+
return false;
53+
}
54+
}

src/Controller/AdminInterface/Docs/KnownRepositoryDomainsController.php

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@
1313

1414
use App\Entity\KnownRepositoryDomain;
1515
use App\Enum\DocumentationRenderingTrigger;
16+
use App\Exception\DocumentationRenderingRequestDeclinedException;
1617
use App\Form\KnownDomainCreateType;
1718
use App\Form\KnownDomainDeleteType;
1819
use App\Repository\KnownRepositoryDomainRepository;
@@ -69,7 +70,13 @@ public function new(Request $request): Response
6970
if ($data->isAllowed()) {
7071
foreach ($this->documentationQuarantineService->findAllByDomain($data->getDomain()) as $documentationQuarantine) {
7172
$pushEvent = $documentationQuarantine->getPushEvent();
72-
$this->renderDocumentationService->requestDocumentationRendering($pushEvent, DocumentationRenderingTrigger::WEB);
73+
try {
74+
$this->renderDocumentationService->requestDocumentationRendering($pushEvent, DocumentationRenderingTrigger::WEB);
75+
} catch (DocumentationRenderingRequestDeclinedException) {
76+
// An entry can be undeployable for reasons that have nothing to
77+
// do with the domain, an irrelevant branch name for instance.
78+
// Keep going, one such entry must not stop the others.
79+
}
7380

7481
$this->entityManager->remove($documentationQuarantine);
7582
}

src/Controller/AdminInterface/Docs/QuarantinedDocumentationsController.php

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -71,20 +71,26 @@ public function allow(Request $request, DocumentationQuarantine $quarantinedDocu
7171
$this->entityManager->persist($knownRepositoryDomain);
7272
$this->entityManager->flush();
7373

74+
$declined = 0;
7475
foreach ($this->documentationQuarantineService->findAllByDomain($domain) as $documentationQuarantine) {
7576
$pushEvent = $documentationQuarantine->getPushEvent();
7677
try {
7778
$this->renderDocumentationService->requestDocumentationRendering($pushEvent, DocumentationRenderingTrigger::WEB);
7879
} catch (DocumentationRenderingRequestDeclinedException) {
79-
// Exception is thrown if the request documentation rendering does not comply with requirements
80-
// Intended fall-thru
80+
// An entry can be undeployable for reasons that have nothing to do
81+
// with the domain, an irrelevant branch name for instance. Keep
82+
// going, one such entry must not stop the others.
83+
++$declined;
8184
}
8285

8386
$this->entityManager->remove($documentationQuarantine);
8487
}
8588
$this->entityManager->flush();
8689

8790
$this->addFlash('success', sprintf('The domain %s has been allowed and all quarantined renderings have been activated.', $domain));
91+
if ($declined > 0) {
92+
$this->addFlash('warning', sprintf('%d of them could not be rendered and were discarded, see the rendering history for the reason.', $declined));
93+
}
8894

8995
return $this->redirectToRoute('admin_docs_quarantine_index');
9096
}

src/Service/DocumentationQuarantineService.php

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,6 @@
1414
use App\Entity\DocumentationQuarantine;
1515
use App\Extractor\PushEvent;
1616
use App\Repository\DocumentationQuarantineRepository;
17-
use App\Utility\RepositoryUrlUtility;
1817
use Doctrine\ORM\EntityManagerInterface;
1918

2019
class DocumentationQuarantineService
@@ -32,10 +31,15 @@ public function isQuarantined(PushEvent $pushEvent): bool
3231
]);
3332
}
3433

35-
public function quarantine(PushEvent $pushEvent): DocumentationQuarantine
34+
/**
35+
* The domain has to be the one the request was actually blocked on, which is
36+
* the host of the composer.json url. It differs from the host of the clone url
37+
* for Github always, and can differ for the other services as well.
38+
*/
39+
public function quarantine(PushEvent $pushEvent, string $blockedDomain): DocumentationQuarantine
3640
{
3741
$documentationQuarantine = (new DocumentationQuarantine())
38-
->setDomain(RepositoryUrlUtility::getNormalizedDomain($pushEvent->getRepositoryUrl()))
42+
->setDomain($blockedDomain)
3943
->setSerializedPushEvent(json_encode($pushEvent, JSON_THROW_ON_ERROR))
4044
->setChecksum($this->hash($pushEvent));
4145

src/Service/RenderDocumentationService.php

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -55,12 +55,12 @@ public function requestDocumentationRendering(PushEvent $pushEvent, Documentatio
5555
$userIdentifier = $this->security->getUser() instanceof KeyCloakUser ? $this->security->getUser()->getDisplayName() : 'Anon.';
5656

5757
try {
58-
$this->documentationBuildInformationService->updateLastHit(RepositoryUrlUtility::getNormalizedDomain($pushEvent->getRepositoryUrl()));
58+
$this->documentationBuildInformationService->updateLastHit(RepositoryUrlUtility::getNormalizedDomain($pushEvent->getUrlToComposerFile()));
5959

6060
$composerJson = $this->documentationBuildInformationService->fetchRemoteComposerJson($pushEvent->getUrlToComposerFile());
6161
} catch (UnknownComposerJsonUrlException $e) {
6262
if (!$this->documentationQuarantineService->isQuarantined($pushEvent)) {
63-
$documentationQuarantine = $this->documentationQuarantineService->quarantine($pushEvent);
63+
$documentationQuarantine = $this->documentationQuarantineService->quarantine($pushEvent, $e->normalizedHost);
6464
$this->documentationBuildInformationService->notifyAboutUnknownRepositoryDomain($documentationQuarantine);
6565

6666
$this->historyService->writeHistory(new HistoryEntryDto(
Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
/*
6+
* This file is part of the package t3g/intercept.
7+
*
8+
* For the full copyright and license information, please read the
9+
* LICENSE file that was distributed with this source code.
10+
*/
11+
12+
namespace App\Tests\Unit\Service;
13+
14+
use App\Entity\DocumentationQuarantine;
15+
use App\Extractor\PushEvent;
16+
use App\Repository\DocumentationQuarantineRepository;
17+
use App\Service\DocumentationQuarantineService;
18+
use Doctrine\ORM\EntityManagerInterface;
19+
use PHPUnit\Framework\TestCase;
20+
21+
class DocumentationQuarantineServiceTest extends TestCase
22+
{
23+
/**
24+
* Approving a quarantined entry allowlists the domain it recorded, and that
25+
* decision is only correct if the recorded domain is the one the request was
26+
* blocked on. For Github those two never match: the clone url is on
27+
* github.com while the composer.json is fetched from
28+
* raw.githubusercontent.com.
29+
*/
30+
public function testQuarantineRecordsTheDomainTheRequestWasBlockedOn(): void
31+
{
32+
$persisted = null;
33+
$entityManager = $this->createMock(EntityManagerInterface::class);
34+
$entityManager->method('persist')->willReturnCallback(
35+
static function (object $entity) use (&$persisted): void {
36+
$persisted = $entity;
37+
}
38+
);
39+
40+
$subject = new DocumentationQuarantineService($entityManager, $this->createMock(DocumentationQuarantineRepository::class));
41+
$pushEvent = new PushEvent(
42+
'https://github.com/acme/coolextension.git',
43+
'main',
44+
'https://raw.githubusercontent.com/acme/coolextension/main/composer.json',
45+
'{}'
46+
);
47+
48+
$subject->quarantine($pushEvent, 'raw.githubusercontent.com');
49+
50+
$this->assertInstanceOf(DocumentationQuarantine::class, $persisted);
51+
$this->assertSame('raw.githubusercontent.com', $persisted->getDomain());
52+
}
53+
}
Lines changed: 91 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,91 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
/*
6+
* This file is part of the package t3g/intercept.
7+
*
8+
* For the full copyright and license information, please read the
9+
* LICENSE file that was distributed with this source code.
10+
*/
11+
12+
namespace App\Tests\Unit\Service;
13+
14+
use App\Entity\DocumentationQuarantine;
15+
use App\Enum\DocumentationRenderingTrigger;
16+
use App\Exception\DocumentationRenderingRequestDeclinedException;
17+
use App\Exception\UnknownComposerJsonUrlException;
18+
use App\Extractor\PushEvent;
19+
use App\Repository\RepositoryBlacklistEntryRepository;
20+
use App\Service\DocumentationBuildInformationService;
21+
use App\Service\DocumentationQuarantineService;
22+
use App\Service\GithubService;
23+
use App\Service\HistoryService;
24+
use App\Service\MailService;
25+
use App\Service\RenderDocumentationService;
26+
use Doctrine\ORM\EntityManagerInterface;
27+
use PHPUnit\Framework\TestCase;
28+
use Psr\Log\NullLogger;
29+
use Symfony\Bundle\SecurityBundle\Security;
30+
31+
class RenderDocumentationServiceTest extends TestCase
32+
{
33+
/**
34+
* The quarantined domain is what an admin later allowlists, so it has to be
35+
* the host the request was blocked on. That is the host of the composer.json
36+
* url, which for Github is never the host of the clone url.
37+
*/
38+
public function testTheBlockedDomainIsHandedToTheQuarantine(): void
39+
{
40+
$pushEvent = new PushEvent(
41+
'https://github.com/acme/coolextension.git',
42+
'main',
43+
'https://raw.githubusercontent.com/acme/coolextension/main/composer.json',
44+
'{}'
45+
);
46+
47+
$buildInformationService = $this->createMock(DocumentationBuildInformationService::class);
48+
$buildInformationService->method('fetchRemoteComposerJson')->willThrowException(
49+
new UnknownComposerJsonUrlException('', 1782290340, null, $pushEvent->getUrlToComposerFile(), 'raw.githubusercontent.com')
50+
);
51+
52+
$lastHitDomain = null;
53+
$buildInformationService->method('updateLastHit')->willReturnCallback(
54+
static function (string $domain) use (&$lastHitDomain): void {
55+
$lastHitDomain = $domain;
56+
}
57+
);
58+
59+
$quarantinedDomain = null;
60+
$quarantineService = $this->createMock(DocumentationQuarantineService::class);
61+
$quarantineService->method('isQuarantined')->willReturn(false);
62+
$quarantineService->method('quarantine')->willReturnCallback(
63+
static function (PushEvent $event, string $domain) use (&$quarantinedDomain): DocumentationQuarantine {
64+
$quarantinedDomain = $domain;
65+
66+
return new DocumentationQuarantine();
67+
}
68+
);
69+
70+
$subject = new RenderDocumentationService(
71+
$buildInformationService,
72+
$this->createMock(GithubService::class),
73+
new HistoryService($this->createMock(EntityManagerInterface::class)),
74+
new NullLogger(),
75+
$quarantineService,
76+
$this->createMock(RepositoryBlacklistEntryRepository::class),
77+
$this->createMock(MailService::class),
78+
$this->createMock(Security::class),
79+
);
80+
81+
try {
82+
$subject->requestDocumentationRendering($pushEvent, DocumentationRenderingTrigger::API);
83+
$this->fail('An unknown domain has to decline the rendering request.');
84+
} catch (DocumentationRenderingRequestDeclinedException) {
85+
// expected, the assertions below are what this test is about
86+
}
87+
88+
$this->assertSame('raw.githubusercontent.com', $quarantinedDomain, 'The quarantine has to record the host the request was blocked on.');
89+
$this->assertSame('raw.githubusercontent.com', $lastHitDomain, 'The last hit belongs to the domain row the check looks up.');
90+
}
91+
}

0 commit comments

Comments
 (0)