System > HTTPS and cms:https switch the redirect lines in public/.htaccess only when the site answers over HTTPS, and take them back on a loop. HSTS is a separate second switch. The redirect block moves before the front controller rules, where it never reached pages, and .well-known is no longer denied, so the origin probe works on Apache. The Git updater keeps switched-on lines like the archive update. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
168 lines
7.3 KiB
PHP
168 lines
7.3 KiB
PHP
<?php
|
|
|
|
namespace App\Tests;
|
|
|
|
use App\Ops\Download;
|
|
use App\Ops\HttpsRedirect;
|
|
use App\Ops\OriginCheck;
|
|
use PHPUnit\Framework\TestCase;
|
|
|
|
/**
|
|
* HTTPS erzwingen aus der Verwaltung (ADR 120): eingeschaltet wird nur, wenn HTTPS vorher und
|
|
* nachher hier ankommt, und eine Schleife wird zurückgenommen, bevor sie jemanden aussperrt.
|
|
*/
|
|
final class HttpsRedirectTest extends TestCase
|
|
{
|
|
private string $root;
|
|
private string $shipped;
|
|
/** @var list<?string> */
|
|
private array $answers = [];
|
|
/** @var array{status: int, location: string, error: string} */
|
|
private array $http = ['status' => 301, 'location' => 'https://www.example.de/.well-known/kantsites-origin', 'error' => ''];
|
|
|
|
protected function setUp(): void
|
|
{
|
|
$this->root = sys_get_temp_dir().'/kant-https-'.bin2hex(random_bytes(4));
|
|
mkdir($this->root.'/public', 0o777, true);
|
|
$this->shipped = (string) file_get_contents(dirname(__DIR__).'/public/.htaccess');
|
|
file_put_contents($this->root.'/public/.htaccess', $this->shipped);
|
|
}
|
|
|
|
protected function tearDown(): void
|
|
{
|
|
@unlink($this->root.'/public/.htaccess');
|
|
@rmdir($this->root.'/public');
|
|
@rmdir($this->root);
|
|
}
|
|
|
|
public function testTheShippedFileHasTheRedirectBeforeTheFrontController(): void
|
|
{
|
|
// Hinter "RewriteRule ^index\.php$ - [L]" kam die Umleitung für Seiten nie an die Reihe:
|
|
// Nach dem Umschreiben auf index.php beginnt die Runde neu und endet an dieser Regel.
|
|
$redirect = strpos($this->shipped, '#'.HttpsRedirect::REDIRECT_LINES[0]);
|
|
$front = strpos($this->shipped, 'RewriteRule ^index\.php$ - [L]');
|
|
self::assertNotFalse($redirect);
|
|
self::assertNotFalse($front);
|
|
self::assertLessThan($front, $redirect);
|
|
self::assertSame(['redirect' => false, 'hsts' => false, 'found' => true, 'writable' => true], $this->redirect()->state());
|
|
}
|
|
|
|
public function testEnablingProbesBeforeAndAfterAndSwitchesOnlyTheThreeLines(): void
|
|
{
|
|
$this->answers = ['ok', 'ok'];
|
|
$result = $this->redirect()->enableRedirect('https://www.example.de/');
|
|
self::assertTrue($result['ok'], $result['message']);
|
|
self::assertStringContainsString('leitet mit 301', $result['message']);
|
|
$now = (string) file_get_contents($this->root.'/public/.htaccess');
|
|
foreach (HttpsRedirect::REDIRECT_LINES as $line) {
|
|
self::assertMatchesRegularExpression('/^'.preg_quote($line, '/').'$/m', $now);
|
|
}
|
|
self::assertStringContainsString('#'.HttpsRedirect::HSTS_LINE, $now, 'HSTS bleibt ein eigener Schritt.');
|
|
self::assertSame([], $this->answers, 'Vorher und nachher geprüft.');
|
|
|
|
$off = $this->redirect()->disableRedirect();
|
|
self::assertTrue($off['ok']);
|
|
self::assertSame($this->shipped, (string) file_get_contents($this->root.'/public/.htaccess'), 'Aus heißt: wieder wie ausgeliefert.');
|
|
}
|
|
|
|
public function testNothingChangesWithoutWorkingHttps(): void
|
|
{
|
|
$this->answers = [null];
|
|
$result = $this->redirect()->enableRedirect('https://www.example.de');
|
|
self::assertFalse($result['ok']);
|
|
self::assertStringContainsString('bleibt die Umleitung aus', $result['message']);
|
|
self::assertSame($this->shipped, (string) file_get_contents($this->root.'/public/.htaccess'));
|
|
|
|
$result = $this->redirect()->enableRedirect('http://www.example.de');
|
|
self::assertFalse($result['ok']);
|
|
self::assertStringContainsString('https://', $result['message']);
|
|
}
|
|
|
|
public function testALoopIsTakenBackAtOnce(): void
|
|
{
|
|
// Der Server meldet HTTPS nicht an die .htaccess: mit den Zeilen leitet auch https://
|
|
// auf sich selbst um, die Probe danach kommt nicht mehr an.
|
|
$this->answers = ['ok', null];
|
|
$result = $this->redirect()->enableRedirect('https://www.example.de');
|
|
self::assertFalse($result['ok']);
|
|
self::assertStringContainsString('zurückgesetzt', $result['message']);
|
|
self::assertSame($this->shipped, (string) file_get_contents($this->root.'/public/.htaccess'));
|
|
}
|
|
|
|
public function testAServerThatIgnoresTheRuleIsNamed(): void
|
|
{
|
|
$this->answers = ['ok', 'ok'];
|
|
$this->http = ['status' => 200, 'location' => '', 'error' => ''];
|
|
$result = $this->redirect()->enableRedirect('https://www.example.de');
|
|
self::assertTrue($result['ok']);
|
|
self::assertStringContainsString('antwortet mit 200', $result['message']);
|
|
}
|
|
|
|
public function testHstsOnlyAfterTheRedirect(): void
|
|
{
|
|
$this->answers = ['ok'];
|
|
$result = $this->redirect()->switchHsts(true, 'https://www.example.de');
|
|
self::assertFalse($result['ok']);
|
|
self::assertStringContainsString('erst, wenn die Umleitung', $result['message']);
|
|
|
|
$this->answers = ['ok', 'ok', 'ok'];
|
|
$this->redirect()->enableRedirect('https://www.example.de');
|
|
self::assertTrue($this->redirect()->switchHsts(true, 'https://www.example.de')['ok']);
|
|
self::assertSame(['redirect' => true, 'hsts' => true, 'found' => true, 'writable' => true], $this->redirect()->state());
|
|
|
|
// Die Umleitung aus nimmt HSTS mit.
|
|
$this->redirect()->disableRedirect();
|
|
self::assertSame($this->shipped, (string) file_get_contents($this->root.'/public/.htaccess'));
|
|
}
|
|
|
|
public function testAnUpdateKeepsWhatWasSwitchedOn(): void
|
|
{
|
|
$this->answers = ['ok', 'ok', 'ok'];
|
|
$this->redirect()->enableRedirect('https://www.example.de');
|
|
$this->redirect()->switchHsts(true, 'https://www.example.de');
|
|
$merged = HttpsRedirect::keepEnabled($this->shipped, (string) file_get_contents($this->root.'/public/.htaccess'));
|
|
self::assertTrue(HttpsRedirect::active($merged, HttpsRedirect::REDIRECT_LINES));
|
|
self::assertTrue(HttpsRedirect::active($merged, [HttpsRedirect::HSTS_LINE]));
|
|
self::assertSame($this->shipped, HttpsRedirect::keepEnabled($this->shipped, $this->shipped));
|
|
}
|
|
|
|
public function testTheGitUpdaterDoesNotCallTheSwitchedFileALocalChange(): void
|
|
{
|
|
$dirty = new \ReflectionMethod(\App\Ops\Updater::class, 'dirty');
|
|
self::assertSame([], $dirty->invoke(null, " M public/.htaccess\n"));
|
|
self::assertSame([], $dirty->invoke(null, 'M public/.htaccess'), 'Auch wenn die Ausgabe vorn gekürzt ist.');
|
|
self::assertSame([' M src/Kernel.php'], $dirty->invoke(null, " M public/.htaccess\n M src/Kernel.php\n"));
|
|
self::assertSame(['?? public/.htaccess.bak'], $dirty->invoke(null, '?? public/.htaccess.bak'));
|
|
}
|
|
|
|
private function redirect(): HttpsRedirect
|
|
{
|
|
$test = $this;
|
|
$download = new class($test) extends Download {
|
|
public function __construct(private HttpsRedirectTest $test)
|
|
{
|
|
parent::__construct();
|
|
}
|
|
|
|
public function get(string $url): ?string
|
|
{
|
|
return $this->test->nextAnswer();
|
|
}
|
|
};
|
|
$check = new OriginCheck('geheim', $download);
|
|
$this->token = $check->token();
|
|
|
|
return new HttpsRedirect($this->root, $check, fn (string $url): array => $this->http);
|
|
}
|
|
|
|
private string $token = '';
|
|
|
|
public function nextAnswer(): ?string
|
|
{
|
|
self::assertNotSame([], $this->answers, 'Mehr Proben als erwartet.');
|
|
$answer = array_shift($this->answers);
|
|
|
|
return null === $answer ? null : $this->token;
|
|
}
|
|
}
|