CluPilotCloud/tests/Feature/Host/FilesHostTest.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');
});