178 lines
6.9 KiB
PHP
178 lines
6.9 KiB
PHP
<?php
|
|
|
|
use App\Models\Host;
|
|
use App\Support\HostEnrolment;
|
|
use App\Support\HostTakeoverCommand;
|
|
|
|
beforeEach(function () {
|
|
fakeServices();
|
|
// Hostname kommt aus phpunit.xml — Routen werden beim Booten gebunden.
|
|
|
|
$this->publicDir = storage_path('app/files/public');
|
|
if (! is_dir($this->publicDir)) {
|
|
mkdir($this->publicDir, 0755, true);
|
|
}
|
|
});
|
|
|
|
function issuedCode(): string
|
|
{
|
|
return HostEnrolment::issue(Host::factory()->create());
|
|
}
|
|
|
|
/**
|
|
* Der private Teil: ohne Ausweis gibt es das Archiv nicht — und zwar als 404,
|
|
* nicht als 403. Dieselbe Regel wie bei der Konsole: ein Fremder soll nicht
|
|
* erfahren, dass es hier etwas gibt.
|
|
*/
|
|
it('refuses the installer without a code', function () {
|
|
$this->get('http://files.clupilot.test/bootstrap.tar.gz')->assertNotFound();
|
|
$this->get('http://files.clupilot.test/bootstrap.tar.gz?code=erfunden')->assertNotFound();
|
|
});
|
|
|
|
it('serves the installer to a valid code', function () {
|
|
$response = $this->get('http://files.clupilot.test/bootstrap.tar.gz?code='.issuedCode());
|
|
|
|
$response->assertOk();
|
|
expect($response->headers->get('Content-Type'))->toBe('application/gzip')
|
|
->and($response->headers->get('X-Robots-Tag'))->toContain('noindex');
|
|
});
|
|
|
|
/**
|
|
* Der Code wird beim Herunterladen NICHT verbraucht. Er wird danach für jede
|
|
* Fortschrittsmeldung und zuletzt für die Registrierung gebraucht — ein
|
|
* Download, der ihn aufbraucht, macht die Übernahme unmöglich, und zwar
|
|
* nachdem der Server schon läuft.
|
|
*/
|
|
it('does not spend the code on the download', function () {
|
|
$code = issuedCode();
|
|
|
|
$this->get('http://files.clupilot.test/bootstrap.tar.gz?code='.$code)->assertOk();
|
|
$this->get('http://files.clupilot.test/bootstrap.tar.gz?code='.$code)->assertOk();
|
|
|
|
expect(HostEnrolment::claim($code))->not->toBeNull();
|
|
});
|
|
|
|
/**
|
|
* Der öffentliche Teil: AGB, AV, TOM. Anonym, ohne Ausweis, ohne Konto.
|
|
*/
|
|
it('serves public documents to anyone', function () {
|
|
file_put_contents($this->publicDir.'/agb-2026-01.pdf', '%PDF-1.4 Testfassung');
|
|
|
|
$response = $this->get('http://files.clupilot.test/agb-2026-01.pdf');
|
|
|
|
$response->assertOk();
|
|
|
|
// `response()->file()` streamt, der Rumpf steht also nicht im Ergebnis.
|
|
// Geprüft wird deshalb die ausgelieferte Datei selbst — und dass sie im
|
|
// Browser ANGEZEIGT wird: eine AGB, die heruntergeladen wird, wird
|
|
// weggeklickt.
|
|
expect($response->baseResponse->getFile()->getPathname())
|
|
->toBe($this->publicDir.'/agb-2026-01.pdf')
|
|
->and($response->headers->get('Content-Disposition'))->toStartWith('inline');
|
|
|
|
unlink($this->publicDir.'/agb-2026-01.pdf');
|
|
});
|
|
|
|
it('does not invent documents that are not there', function () {
|
|
$this->get('http://files.clupilot.test/gibt-es-nicht.pdf')->assertNotFound();
|
|
});
|
|
|
|
/**
|
|
* Der teuerste denkbare Fehlgriff an dieser Stelle: ein Pfad, der aus dem
|
|
* Verzeichnis herausführt. `.env` liegt zwei Ebenen darüber.
|
|
*/
|
|
it('cannot be walked out of its directory', function () {
|
|
foreach (['..%2F..%2F..%2F.env', '....//....//.env', '%2e%2e%2f.env'] as $attempt) {
|
|
$this->get('http://files.clupilot.test/'.$attempt)->assertNotFound();
|
|
}
|
|
|
|
// Und eine Punktdatei, die tatsächlich dort läge, ebenfalls nicht.
|
|
file_put_contents($this->publicDir.'/.geheim', 'nicht ausliefern');
|
|
$this->get('http://files.clupilot.test/.geheim')->assertNotFound();
|
|
unlink($this->publicDir.'/.geheim');
|
|
});
|
|
|
|
/**
|
|
* Die kopierte Zeile holt von diesem Hostnamen und legt den Code bei.
|
|
*/
|
|
it('points the command line at the files host, with the code', function () {
|
|
$host = Host::factory()->create(['dns_name' => 'fsn-01']);
|
|
$enrolment = HostEnrolment::issueWithKeys($host);
|
|
|
|
$command = HostTakeoverCommand::for($host, $enrolment);
|
|
|
|
expect($command)
|
|
->toContain('https://files.clupilot.test/bootstrap.tar.gz?code='.$enrolment['code'])
|
|
->toContain('--code '.$enrolment['code']);
|
|
});
|
|
|
|
/**
|
|
* Ohne FILES_HOST bleibt alles, wo es war. Sonst bräche eine schon ausgegebene
|
|
* Befehlszeile in dem Moment, in dem jemand die Einstellung setzt, aber der
|
|
* DNS-Eintrag noch nicht steht.
|
|
*/
|
|
it('falls back to the portal host until the files host exists', function () {
|
|
config()->set('admin_access.files_host', '');
|
|
config()->set('admin_access.app_host', 'app.clupilot.com');
|
|
|
|
expect(HostTakeoverCommand::archiveUrl())
|
|
->toBe('https://app.clupilot.com/bootstrap.tar.gz');
|
|
});
|
|
|
|
/**
|
|
* Der Fund, der diesen Test nötig gemacht hat.
|
|
*
|
|
* `PublicSiteGate` hängt an der ganzen `web`-Gruppe. Solange die Website
|
|
* verborgen ist, bekäme ein Server im Rettungssystem die 503-Platzhalterseite
|
|
* und schöbe sie in `tar xz` — der Betreiber sähe einen Entpackfehler auf einer
|
|
* Maschine, an die er nur noch über die Anbieterkonsole kommt.
|
|
*/
|
|
it('serves downloads even while the site is hidden', function () {
|
|
App\Support\Settings::set('site.public', false);
|
|
|
|
// Von einer fremden Adresse, denn genau die hat ein Server im
|
|
// Rettungssystem: 127.0.0.1 steht in TRUSTED_RANGES und käme ohnehin durch,
|
|
// der Test bewiese dann nichts.
|
|
$vonAussen = ['REMOTE_ADDR' => '203.0.113.77'];
|
|
|
|
$this->withServerVariables($vonAussen)
|
|
->get('http://files.clupilot.test/bootstrap.tar.gz?code='.issuedCode())->assertOk();
|
|
|
|
file_put_contents($this->publicDir.'/tom-2026-01.pdf', '%PDF-1.4');
|
|
$this->withServerVariables($vonAussen)
|
|
->get('http://files.clupilot.test/tom-2026-01.pdf')->assertOk();
|
|
unlink($this->publicDir.'/tom-2026-01.pdf');
|
|
|
|
// Und die Website selbst bleibt für dieselbe Adresse verborgen — sonst
|
|
// hätte die Ausnahme mehr aufgemacht als sie sollte.
|
|
$this->withServerVariables($vonAussen)->get('http://localhost/')->assertStatus(503);
|
|
});
|
|
|
|
/**
|
|
* Der Datei-Hostname bietet genau zwei Dinge an — und sonst nichts.
|
|
*
|
|
* Ohne diese Grenze fiel jede andere Anfrage auf die host-unabhängigen Routen
|
|
* durch und landete beim Portal: wer `files.…` aufrief, wurde nach `app.…`
|
|
* weitergeleitet. Das sagt jedem, der den Namen probiert, wo das Portal liegt
|
|
* und dass beide auf derselben Maschine sitzen.
|
|
*/
|
|
it('offers nothing but the archive and the public files', function () {
|
|
foreach (['/', '/dashboard', '/login', '/admin', '/tief/verschachtelt/pfad'] as $pfad) {
|
|
$antwort = $this->get('http://files.clupilot.test'.$pfad);
|
|
|
|
$antwort->assertNotFound();
|
|
// Und ausdrücklich KEINE Weiterleitung: eine 302 auf app.… wäre genau
|
|
// der Hinweis, den dieser Hostname nicht geben soll.
|
|
expect($antwort->headers->get('Location'))->toBeNull();
|
|
}
|
|
});
|
|
|
|
it('still serves its two things after that boundary', function () {
|
|
file_put_contents($this->publicDir.'/av-2026-01.pdf', '%PDF-1.4');
|
|
|
|
$this->get('http://files.clupilot.test/av-2026-01.pdf')->assertOk();
|
|
$this->get('http://files.clupilot.test/bootstrap.tar.gz?code='.issuedCode())->assertOk();
|
|
|
|
unlink($this->publicDir.'/av-2026-01.pdf');
|
|
});
|