From 499240dd45810a869d0d24a685a78a2ed8d2bc53 Mon Sep 17 00:00:00 2001 From: nexxo Date: Mon, 3 Aug 2026 19:59:44 +0200 Subject: [PATCH] Die Kundeninstanz lernt Mail verschicken MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bis hierher konnte sie es nicht — keine Freigabe-Benachrichtigung, kein "Passwort vergessen", nichts. Das ist die Voraussetzung dafuer, dass die Einladung an einen Mitarbeiter aus SEINER Nextcloud kommt und das Passwort dort entsteht, wo niemand sonst es sieht. Ohne Server oder Postfach wird nichts geschrieben und der Schritt scheitert mit Grund. Zwei bestehende Pruefungen mitgezogen: CustomerStepBaseTest erwartete eine feste Schrittzahl (16 -> 17), und CustomerProvisioningEndToEndTest lief ohne Mailversand-Fixtures durch die volle Pipeline und scheiterte jetzt genau dort — beide auf dieselbe Art nachgezogen wie ApplyStorageQuota es vormacht. Co-Authored-By: Claude Opus 5 --- .../Steps/Customer/ConfigureInstanceMail.php | 96 ++++++++++++++ config/provisioning.php | 13 ++ .../ConfigureInstanceMailTest.php | 118 ++++++++++++++++++ .../CustomerProvisioningEndToEndTest.php | 18 ++- .../Provisioning/CustomerStepBaseTest.php | 2 +- 5 files changed, 245 insertions(+), 2 deletions(-) create mode 100644 app/Provisioning/Steps/Customer/ConfigureInstanceMail.php create mode 100644 tests/Feature/Provisioning/ConfigureInstanceMailTest.php diff --git a/app/Provisioning/Steps/Customer/ConfigureInstanceMail.php b/app/Provisioning/Steps/Customer/ConfigureInstanceMail.php new file mode 100644 index 0000000..d6f3aa9 --- /dev/null +++ b/app/Provisioning/Steps/Customer/ConfigureInstanceMail.php @@ -0,0 +1,96 @@ +instance($run); + + if ($instance === null) { + return StepResult::fail('instance_missing'); + } + + $config = GuestMailConfig::for($instance); + + if (! $config->available()) { + return StepResult::fail($config->problem() ?? 'mail_unavailable'); + } + + $pve = $this->pve->forHost($instance->host); + + // Der Schluessel ist ein fester occ-Name aus GuestMailConfig::values(), + // keine Benutzereingabe — genauso unmaskiert wie 'files default_quota' + // in ApplyStorageQuota. Maskiert wird nur der WERT, siehe Kopfkommentar. + foreach ($config->values() as $schluessel => $wert) { + $this->guest($pve, $run, NextcloudOcc::command( + 'config:system:set '.$schluessel.' --value='.escapeshellarg($wert) + )); + } + + // Das Passwort wie jeder andere Wert. Verstecken laesst es sich auf + // dieser Maschine nicht — siehe Kopfkommentar. + $this->guest($pve, $run, NextcloudOcc::command( + 'config:system:set mail_smtppassword --value='.escapeshellarg($config->password()) + )); + + return StepResult::advance(); + } +} diff --git a/config/provisioning.php b/config/provisioning.php index 5185797..9ae8917 100644 --- a/config/provisioning.php +++ b/config/provisioning.php @@ -80,6 +80,10 @@ return [ Customer\ConfigureNetwork::class, Customer\DeployApplicationStack::class, Customer\ConfigureNextcloud::class, + // Ohne diesen Schritt verschickt die Kundeninstanz ueberhaupt + // keine Mail — auch nicht die Einladung an einen Mitarbeiter, die + // Nextcloud selbst verschickt. + Customer\ConfigureInstanceMail::class, // The allowance the customer paid for, enforced on the machine. // It was never applied anywhere before: quota_gb reached the // instance row and stopped there, so every package delivered the @@ -241,6 +245,15 @@ return [ Host\SecureHostFirewall::class, ], + /* + | Einer bestehenden Instanz den Mailversand nachtragen. Ein Schritt, + | derselbe wie im Aufbau — ein zweiter Weg, dieselben Werte zu + | schreiben, wuerde driften. + */ + 'instance-mail' => [ + Customer\ConfigureInstanceMail::class, + ], + /* | A storage pack bought, or given back. | diff --git a/tests/Feature/Provisioning/ConfigureInstanceMailTest.php b/tests/Feature/Provisioning/ConfigureInstanceMailTest.php new file mode 100644 index 0000000..6825eb9 --- /dev/null +++ b/tests/Feature/Provisioning/ConfigureInstanceMailTest.php @@ -0,0 +1,118 @@ +create([ + 'pipeline' => 'instance-mail', + 'context' => ['instance_id' => $instance->id, 'node' => 'pve', 'vmid' => 201], + ]); +} + +/** + * Eine Instanz, die tatsaechlich auf einem Host steht. + * + * Der Schritt ruft ProxmoxClient::forHost($instance->host) auf — genau wie + * ApplyStorageQuota, dessen Testaufbau (ApplyStorageQuotasTest::unquotedInstance()) + * aus demselben Grund einen Host anlegt. Eine Instanz ohne host_id gibt es in + * diesem Bestand fuer eine Maschine, die tatsaechlich Gastbefehle bekommt, + * nicht — FakeProxmoxClient::forHost() verlangt ein echtes Host-Objekt, weil + * der reale Client genauso wenig ohne einen Host wuesste, wohin er soll. + */ +function instanzAufHost(): Instance +{ + return Instance::factory()->create(['host_id' => Host::factory()]); +} + +function versandbereitFuerSchritt(): void +{ + Settings::set('mail.host', 'mail.clupilot.cloud'); + Settings::set('mail.port', 587); + Settings::set('mail.encryption', 'tls'); + Mailbox::factory()->create([ + 'key' => 'instance-relay', 'address' => 'noreply@clupilot.cloud', + 'username' => 'noreply@clupilot.cloud', 'password' => 'geheim', + 'active' => true, 'authenticates' => true, + ]); +} + +it('traegt jeden Wert einzeln in den Gast', function () { + versandbereitFuerSchritt(); + $pve = new FakeProxmoxClient; + app()->instance(ProxmoxClient::class, $pve); + $instance = instanzAufHost(); + + app(ConfigureInstanceMail::class)->execute(laufMitInstanz($instance)); + + $befehle = implode("\n", $pve->guestCommands); + + expect($befehle)->toContain('config:system:set mail_smtphost --value=') + ->and($befehle)->toContain('mail.clupilot.cloud') + ->and($befehle)->toContain('config:system:set mail_domain --value=') + ->and($befehle)->toContain('clupilot.cloud'); +}); + +it('maskiert das Passwort, sodass es keinen zweiten Befehl starten kann', function () { + // Verstecken laesst sich der Wert auf dieser Maschine nicht (siehe + // Kopfkommentar des Schrittes). Was sehr wohl gilt und geprueft gehoert: + // er darf aus seiner Klammerung nicht ausbrechen. Der Befehl laeuft als + // root auf einer Kundenmaschine — ein Semikolon im Passwort waere dort + // ein zweiter Befehl. + Settings::set('mail.host', 'mail.clupilot.cloud'); + Settings::set('mail.port', 587); + Mailbox::factory()->create([ + 'key' => 'instance-relay', 'address' => 'noreply@clupilot.cloud', + 'username' => 'noreply@clupilot.cloud', + 'password' => "boes'; touch /tmp/PWNED; echo '", + 'active' => true, 'authenticates' => true, + ]); + + $pve = new FakeProxmoxClient; + app()->instance(ProxmoxClient::class, $pve); + + app(ConfigureInstanceMail::class)->execute(laufMitInstanz(instanzAufHost())); + + $passwortbefehl = collect($pve->guestCommands) + ->first(fn ($b) => str_contains($b, 'mail_smtppassword')); + + // Der ganze Wert steht in EINEM maskierten Argument: das Semikolon darf + // nicht ausserhalb der Anfuehrungszeichen stehen. + expect($passwortbefehl)->toContain(escapeshellarg("boes'; touch /tmp/PWNED; echo '")); +}); + +it('schreibt GAR NICHTS, wenn der Mailserver fehlt', function () { + Settings::set('mail.host', ''); + $pve = new FakeProxmoxClient; + app()->instance(ProxmoxClient::class, $pve); + + $ergebnis = app(ConfigureInstanceMail::class)->execute(laufMitInstanz(Instance::factory()->create())); + + expect($pve->guestCommands)->toBe([]) + ->and($ergebnis->type)->toBe(\App\Provisioning\StepResult::FAIL) + ->and($ergebnis->reason)->toBe('no_server'); +}); + +it('schreibt beim zweiten Lauf erneut, statt sich mit einem Merker zu sperren', function () { + versandbereitFuerSchritt(); + $pve = new FakeProxmoxClient; + app()->instance(ProxmoxClient::class, $pve); + $instance = instanzAufHost(); + + app(ConfigureInstanceMail::class)->execute(laufMitInstanz($instance)); + $ersteRunde = count($pve->guestCommands); + app(ConfigureInstanceMail::class)->execute(laufMitInstanz($instance)); + + // config:system:set ist von sich aus wiederholbar — derselbe Wert zweimal + // geschrieben ist derselbe Wert. Der Schritt darf deshalb ohne Merker + // erneut laufen; das ist bei einer Nachruestung ueber den Bestand der + // Normalfall, nicht die Ausnahme. + expect(count($pve->guestCommands))->toBe($ersteRunde * 2); +}); diff --git a/tests/Feature/Provisioning/CustomerProvisioningEndToEndTest.php b/tests/Feature/Provisioning/CustomerProvisioningEndToEndTest.php index f2426e8..2232296 100644 --- a/tests/Feature/Provisioning/CustomerProvisioningEndToEndTest.php +++ b/tests/Feature/Provisioning/CustomerProvisioningEndToEndTest.php @@ -2,12 +2,14 @@ use App\Models\Host; use App\Models\Instance; +use App\Models\Mailbox; use App\Models\Order; use App\Models\ProvisioningRun; use App\Models\RunResource; use App\Notifications\CloudReady; use App\Provisioning\RunRunner; use App\Provisioning\Steps\Customer\CloneVirtualMachine; +use App\Support\Settings; use Illuminate\Support\Facades\Notification; use Illuminate\Support\Facades\Queue; @@ -20,6 +22,20 @@ it('provisions a paid order all the way to active (mocked)', function () { $s['pve']->guestScript('occ status', 0, '{"installed":true,"maintenance":false}'); // deploy health + acceptance probe // Fake defaults: tasks stopped/OK, guest agent up, cert reachable. + // ConfigureInstanceMail faellt jetzt in dieser Pipeline: ohne Server und + // Absenderpostfach faellt der ganze Lauf (Absicht, siehe GuestMailConfig + // und ConfigureInstanceMail selbst). Ein frischer Betrieb braucht diese + // Einrichtung einmalig, genau wie den Hetzner-DNS-Token oder den + // Stripe-Schluessel — hier nachgestellt, damit dieser Lauf ueberhaupt bis + // zum Ende kommt. + Settings::set('mail.host', 'mail.clupilot.cloud'); + Settings::set('mail.port', 587); + Mailbox::factory()->create([ + 'key' => 'instance-relay', 'address' => 'noreply@clupilot.cloud', + 'username' => 'noreply@clupilot.cloud', 'password' => 'geheim', + 'active' => true, 'authenticates' => true, + ]); + Host::factory()->active()->create(['datacenter' => 'fsn', 'node' => 'pve', 'total_gb' => 1000]); $order = Order::factory()->withSubscription()->create(['status' => 'paid', 'plan' => 'start', 'datacenter' => 'fsn']); $run = ProvisioningRun::factory()->create([ @@ -49,7 +65,7 @@ it('provisions a paid order all the way to active (mocked)', function () { ->and($instance->cert_ok)->toBeTrue() ->and($instance->onboardingTasks()->count())->toBeGreaterThan(0) // The storage allowance was DELIVERED, not merely walked past. This test - // covered all sixteen steps and asserted nothing about the quota one, so it + // covers every step of the pipeline and asserted nothing about the quota one, so it // proved that inserting the step did not break the pipeline rather than // that the step does anything — and quota_applied_gb is written only after // the guest accepted the figure, which is the whole distinction between an diff --git a/tests/Feature/Provisioning/CustomerStepBaseTest.php b/tests/Feature/Provisioning/CustomerStepBaseTest.php index 0125ce6..65df1cd 100644 --- a/tests/Feature/Provisioning/CustomerStepBaseTest.php +++ b/tests/Feature/Provisioning/CustomerStepBaseTest.php @@ -30,7 +30,7 @@ it('builds every customer machine with the storage allowance that was sold', fun // that the quota step is IN it: the allowance was applied nowhere for the // whole life of the pipeline — quota_gb reached the instance row and // stopped — so every package handed out the whole disk. - expect(config('provisioning.pipelines.customer'))->toHaveCount(16) + expect(config('provisioning.pipelines.customer'))->toHaveCount(17) ->toContain(ApplyStorageQuota::class); });