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'); });