diff --git a/app/Console/Commands/PublishProcessingAgreement.php b/app/Console/Commands/PublishProcessingAgreement.php new file mode 100644 index 0000000..12a6cd4 --- /dev/null +++ b/app/Console/Commands/PublishProcessingAgreement.php @@ -0,0 +1,69 @@ +argument('version'); + + if (DpaVersion::query()->where('version', $version)->exists()) { + $this->error("Version {$version} already exists. Pick another name — two documents under one name makes every acceptance ambiguous."); + + return self::FAILURE; + } + + $agreementPath = 'dpa/av-vertrag-'.$this->slug($version).'.pdf'; + $measuresPath = 'dpa/tom-'.$this->slug($version).'.pdf'; + + Storage::disk('local')->put($agreementPath, $renderer->agreement($version)); + Storage::disk('local')->put($measuresPath, $renderer->measures($version)); + + $row = DpaVersion::query()->create([ + 'version' => $version, + 'agreement_path' => $agreementPath, + 'measures_path' => $measuresPath, + 'note' => 'Aus dem Repository erzeugt (clupilot:publish-dpa).', + // Publishing is the point of the command; --draft is for looking at + // it first. Every customer who accepted an earlier version is + // outstanding again from this moment. + 'published_at' => $this->option('draft') ? null : now(), + ]); + + $this->info(($this->option('draft') ? 'Drafted' : 'Published').' version '.$row->version.'.'); + $this->line(' '.$agreementPath.' ('.number_format(strlen(Storage::disk('local')->get($agreementPath)) / 1024, 0).' KB)'); + $this->line(' '.$measuresPath.' ('.number_format(strlen(Storage::disk('local')->get($measuresPath)) / 1024, 0).' KB)'); + + return self::SUCCESS; + } + + private function slug(string $version): string + { + return preg_replace('/[^a-z0-9.-]+/i', '-', $version) ?: 'version'; + } +} diff --git a/app/Services/Legal/DpaRenderer.php b/app/Services/Legal/DpaRenderer.php new file mode 100644 index 0000000..2567f50 --- /dev/null +++ b/app/Services/Legal/DpaRenderer.php @@ -0,0 +1,109 @@ +> + */ + public static function subprocessors(): array + { + return [ + [ + 'name' => 'Hetzner Online GmbH', + 'service' => 'Rechenzentrum, Server und Netzanbindung der Kundeninstanzen', + 'location' => 'Falkenstein und Nürnberg, Deutschland; Helsinki, Finnland', + ], + [ + 'name' => 'Stripe Payments Europe, Ltd.', + 'service' => 'Zahlungsabwicklung (Vertrags- und Zahlungsdaten des Kunden, keine Instanzinhalte)', + 'location' => 'Irland (EU)', + ], + [ + 'name' => 'thinkidoo e.U.', + 'service' => 'Mailserver für den Versand und Empfang der Betriebs- und Support-E-Mails', + 'location' => 'Österreich', + ], + ]; + } + + /** The agreement itself. */ + public function agreement(string $version, ?Carbon $issuedOn = null): string + { + return $this->render('legal.dpa.agreement', $version, $issuedOn, [ + 'subprocessors' => self::subprocessors(), + ]); + } + + /** Annex 1: the technical and organisational measures. */ + public function measures(string $version, ?Carbon $issuedOn = null): string + { + return $this->render('legal.dpa.measures', $version, $issuedOn, [ + // From the commands that enforce them, so the document cannot + // promise one deadline while the sweep runs another. + 'unverifiedDays' => PruneUnverifiedAccounts::AFTER_DAYS, + 'warnDays' => PruneDormantAccounts::WARN_DAYS_BEFORE, + ]); + } + + /** @param array $data */ + private function render(string $view, string $version, ?Carbon $issuedOn, array $data): string + { + $issuedOn ??= now(); + + $html = View::make($view, array_merge($data, [ + 'version' => $version, + // R19: written on the wall clock, because a date on a contract is + // read by a person and not by a server. + 'issuedOn' => $issuedOn->local()->isoFormat('LL'), + ]))->render(); + + $pdf = new TCPDF('P', 'mm', 'A4', true, 'UTF-8'); + $pdf->SetCreator('CluPilot'); + $pdf->SetAuthor((string) (CompanyProfile::get('name') ?? 'CluPilot')); + $pdf->SetTitle(__('dpa.title').' '.$version); + // No default header or footer bar: this is a contract, not a report, and + // TCPDF's default header prints a black line and a title nobody set. + $pdf->setPrintHeader(false); + $pdf->setPrintFooter(true); + $pdf->SetMargins(20, 18, 20); + $pdf->SetAutoPageBreak(true, 20); + // DejaVu, because the text is German: TCPDF's core fonts have no ß in + // the encoding this uses and would print it as a blank. + $pdf->SetFont('dejavusans', '', 9.5); + $pdf->AddPage(); + $pdf->writeHTML($html, true, false, true, false, ''); + + return (string) $pdf->Output('', 'S'); + } +} diff --git a/lang/de/dpa.php b/lang/de/dpa.php index 2760de6..7cd9a97 100644 --- a/lang/de/dpa.php +++ b/lang/de/dpa.php @@ -14,4 +14,5 @@ return [ 'accept_cta' => 'Zur Kenntnis genommen und abgeschlossen', 'accepted_on' => 'Fassung :version abgeschlossen am :when. Bei einer neuen Fassung melden wir uns und bitten Sie erneut um Ihre Zustimmung.', 'accepted_notice' => 'AV-Vertrag abgeschlossen. Sie finden ihn jederzeit hier.', + 'download' => 'Herunterladen', ]; diff --git a/lang/de/dpa_admin.php b/lang/de/dpa_admin.php index 4ac6ec3..1711fb0 100644 --- a/lang/de/dpa_admin.php +++ b/lang/de/dpa_admin.php @@ -32,4 +32,5 @@ return [ 'published' => 'Fassung :version ist in Kraft.', 'publish_title' => 'Fassung :version in Kraft setzen?', 'publish_body' => 'Ab diesem Moment gilt diese Fassung. Alle Kunden, die die bisherige abgeschlossen hatten, sind wieder offen und müssen erneut zustimmen.', + 'download' => 'Herunterladen', ]; diff --git a/lang/en/dpa.php b/lang/en/dpa.php index c99e0e4..598c85b 100644 --- a/lang/en/dpa.php +++ b/lang/en/dpa.php @@ -14,4 +14,5 @@ return [ 'accept_cta' => 'Read and concluded', 'accepted_on' => 'Version :version concluded on :when. When a new version is issued we will tell you and ask again.', 'accepted_notice' => 'Agreement concluded. You can find it here at any time.', + 'download' => 'Download', ]; diff --git a/lang/en/dpa_admin.php b/lang/en/dpa_admin.php index 5a97e5e..96c1802 100644 --- a/lang/en/dpa_admin.php +++ b/lang/en/dpa_admin.php @@ -32,4 +32,5 @@ return [ 'published' => 'Version :version is in force.', 'publish_title' => 'Put version :version in force?', 'publish_body' => 'From this moment this version applies. Every customer who had concluded the previous one is outstanding again and must accept anew.', + 'download' => 'Download', ]; diff --git a/resources/views/legal/dpa/agreement.blade.php b/resources/views/legal/dpa/agreement.blade.php new file mode 100644 index 0000000..6ac35ff --- /dev/null +++ b/resources/views/legal/dpa/agreement.blade.php @@ -0,0 +1,204 @@ +{{-- + Auftragsverarbeitungsvertrag (Art. 28 DSGVO). + + Rendered to PDF by App\Services\Legal\DpaRenderer through TCPDF, so the + markup is deliberately plain: headings, paragraphs, lists and simple tables. + TCPDF's HTML support is not a browser's — no flexbox, no grid, no custom + properties — and anything fancier silently renders as something else. + + The company's own details come from CompanyProfile, so this document and the + invoices cannot name the same company differently. + + ## What this text is and is not + + It is a working agreement built from what this installation actually does: + the sub-processors are the ones the software really talks to, the measures + referenced are the ones in the annex, and the deletion deadlines are the ones + the code enforces. It is NOT a lawyer's opinion, and it should be read by one + before it is relied on in a dispute — particularly the liability clause and + anything a customer negotiates. +--}} +@php + $c = App\Support\CompanyProfile::all(); + $seat = trim(($c['postcode'] ?? '').' '.($c['city'] ?? '')); + + // A register number of "FN 000000a" is a placeholder somebody has not + // replaced yet, and printing it on a contract is worse than leaving the line + // out: it looks like a real number and is not one. Anything whose digits are + // all zeros is treated as unfilled. + $real = fn (?string $value) => $value !== null && $value !== '' && preg_match('/[1-9]/', $value) === 1; +@endphp + +

Vertrag über die Auftragsverarbeitung

+

gemäß Art. 28 der Verordnung (EU) 2016/679 (DSGVO)

+ +

Fassung {{ $version }} · Stand {{ $issuedOn }}

+ +

Die Vertragsparteien

+ +

Auftragsverarbeiter (im Folgenden „CluPilot"):
+{{ $c['name'] ?? 'CluPilot Cloud e.U.' }}
+{{ $c['address'] ?? '' }}
+{{ $seat }}, {{ $c['country'] ?? 'Österreich' }}
+@if ($real($c['register_number'] ?? null)){{ $c['register_number'] }}, {{ $c['register_court'] ?? '' }}
@endif +@if ($real($c['vat_id'] ?? null)){{ $c['vat_id'] }}
@endif +{{ $c['email'] ?? '' }}

+ +

Verantwortlicher (im Folgenden „Kunde"):
+die im Kundenkonto hinterlegten Stammdaten. Der Kunde hält diese Angaben aktuell; sie +sind Bestandteil dieses Vertrags.

+ +

Dieser Vertrag wird elektronisch geschlossen. Art. 28 Abs. 9 DSGVO lässt das +ausdrücklich zu; eine Unterschrift auf Papier ist nicht erforderlich. CluPilot hält +Fassung, Zeitpunkt und IP-Adresse des Abschlusses fest und stellt dem Kunden den +Vertrag im Kundenbereich dauerhaft zum Abruf bereit.

+ +

1. Gegenstand und Dauer

+ +

1.1 CluPilot betreibt für den Kunden eine eigene Nextcloud-Instanz und verarbeitet +dabei personenbezogene Daten ausschließlich im Auftrag und nach Weisung des Kunden. +Der Kunde bleibt Verantwortlicher im Sinne von Art. 4 Z 7 DSGVO.

+ +

1.2 Dieser Vertrag gilt für die Dauer des Hauptvertrags (Nutzung eines CluPilot-Pakets) +und endet mit ihm. Regelungen, die ihrer Natur nach fortwirken — insbesondere +Verschwiegenheit und Löschung —, gelten darüber hinaus.

+ +

2. Art, Zweck und Umfang der Verarbeitung

+ +

2.1 Zweck: Bereitstellung, Betrieb, Wartung und Sicherung einer +Nextcloud-Installation samt zugehöriger Dienste (Speicherung und Freigabe von Dateien, +Kalender, Kontakte, sowie die vom Kunden gebuchten Zusatzmodule).

+ +

2.2 Art der Verarbeitung: Speichern, Aufbewahren, Sichern, Übermitteln im +Rahmen des Dienstes, Löschen. Eine inhaltliche Auswertung der Kundendaten findet nicht +statt.

+ +

2.3 Kategorien betroffener Personen: Beschäftigte, Kundinnen und Kunden, +Mandantinnen und Mandanten, Patientinnen und Patienten, Lieferanten und sonstige +Kontakte des Kunden — je nachdem, wessen Daten der Kunde in seiner Instanz speichert.

+ +

2.4 Kategorien personenbezogener Daten: Stamm- und Kontaktdaten, +Kommunikationsdaten, Inhalts- und Dokumentendaten, Nutzungs- und Protokolldaten der +Instanz. Der Kunde entscheidet allein, welche Daten er speichert; besondere Kategorien +nach Art. 9 DSGVO sind möglich, wenn der Kunde solche Daten einbringt.

+ +

3. Weisungen

+ +

3.1 CluPilot verarbeitet die Daten nur auf dokumentierte Weisung des Kunden. Der +Hauptvertrag samt Leistungsbeschreibung ist die vollständige Weisung für den +Regelbetrieb; weitere Weisungen erteilt der Kunde in Textform an +{{ $c['email'] ?? '' }} oder über den Kundenbereich.

+ +

3.2 Hält CluPilot eine Weisung für rechtswidrig, teilt sie dies dem Kunden unverzüglich +mit und darf die Ausführung bis zur Bestätigung aussetzen (Art. 28 Abs. 3 letzter Satz +DSGVO).

+ +

3.3 Eine Verarbeitung zu eigenen Zwecken findet nicht statt. Davon unberührt bleiben +Daten, die CluPilot als eigener Verantwortlicher verarbeitet — insbesondere die +Vertrags- und Rechnungsdaten des Kunden selbst.

+ +

4. Vertraulichkeit

+ +

CluPilot setzt zur Verarbeitung nur Personen ein, die zur Vertraulichkeit verpflichtet +und über die einschlägigen Bestimmungen des Datenschutzes unterwiesen sind (Art. 28 +Abs. 3 lit. b DSGVO). Die Verpflichtung wirkt über das Ende der Tätigkeit hinaus.

+ +

5. Technische und organisatorische Maßnahmen

+ +

5.1 CluPilot trifft die Maßnahmen nach Art. 32 DSGVO. Sie sind in der Anlage 1 +(TOM) beschrieben, die Bestandteil dieses Vertrags ist.

+ +

5.2 Die Maßnahmen unterliegen dem technischen Fortschritt. CluPilot darf sie +weiterentwickeln, solange das Schutzniveau nicht unterschritten wird; wesentliche +Änderungen werden dem Kunden mit einer neuen Fassung dieses Vertrags mitgeteilt.

+ +

6. Unterauftragsverarbeiter

+ +

6.1 Der Kunde erteilt die allgemeine Genehmigung zur Beauftragung der nachstehenden +Unterauftragsverarbeiter (Art. 28 Abs. 4 DSGVO):

+ + + + + + + + @foreach ($subprocessors as $sub) + + + + + + @endforeach +
UnternehmenLeistungOrt der Verarbeitung
{{ $sub['name'] }}{{ $sub['service'] }}{{ $sub['location'] }}
+ +

6.2 CluPilot informiert den Kunden über die Hinzuziehung oder den Austausch eines +Unterauftragsverarbeiters mindestens vier Wochen vorher in Textform. Der Kunde kann +binnen zwei Wochen ab Zugang aus wichtigem, datenschutzbezogenem Grund widersprechen; +in diesem Fall kann jede Seite den Vertrag zum Wirksamwerden der Änderung kündigen.

+ +

6.3 CluPilot verpflichtet jeden Unterauftragsverarbeiter auf Pflichten, die diesem +Vertrag entsprechen, und haftet für dessen Verhalten wie für eigenes.

+ +

7. Ort der Verarbeitung, Drittländer

+ +

Die Verarbeitung findet ausschließlich in Mitgliedstaaten der Europäischen Union +statt. Eine Übermittlung in ein Drittland erfolgt nicht; sollte sie künftig +erforderlich werden, geschieht dies nur unter den Voraussetzungen der Art. 44 ff. +DSGVO und nach vorheriger Information des Kunden.

+ +

8. Unterstützung des Kunden

+ +

8.1 CluPilot unterstützt den Kunden mit geeigneten Maßnahmen bei der Erfüllung von +Betroffenenrechten (Art. 12 bis 23 DSGVO). Wendet sich eine betroffene Person direkt an +CluPilot, wird sie an den Kunden verwiesen und der Kunde unverzüglich verständigt.

+ +

8.2 CluPilot unterstützt den Kunden bei den Pflichten nach Art. 32 bis 36 DSGVO, +insbesondere bei Datenschutz-Folgenabschätzungen und bei Meldungen an die Aufsichtsbehörde.

+ +

8.3 Verletzungen des Schutzes personenbezogener Daten meldet CluPilot dem Kunden +unverzüglich, spätestens innerhalb von 24 Stunden ab Kenntnis, mit den nach Art. 33 +Abs. 3 DSGVO erforderlichen Angaben, soweit sie vorliegen. Die Meldung an die +Aufsichtsbehörde obliegt dem Kunden.

+ +

9. Nachweise und Kontrollen

+ +

9.1 CluPilot stellt dem Kunden auf Anfrage die zum Nachweis der Einhaltung dieses +Vertrags erforderlichen Informationen zur Verfügung, insbesondere die jeweils geltende +Fassung der Anlage 1.

+ +

9.2 Der Kunde ist berechtigt, sich von der Einhaltung zu überzeugen. Kontrollen finden +nach Anmeldung mit angemessener Frist, während der üblichen Geschäftszeiten und ohne +Störung des Betriebsablaufs statt. CluPilot kann eine Kontrolle durch Vorlage geeigneter +Nachweise ersetzen, soweit dies zur Überprüfung ausreicht. Der Zutritt zu Räumen Dritter +richtet sich nach den Regeln des jeweiligen Unterauftragsverarbeiters.

+ +

10. Löschung und Rückgabe

+ +

10.1 Nach Beendigung des Hauptvertrags stellt CluPilot dem Kunden dessen Daten in +einem gängigen Format zum Abruf bereit. Die Bereitstellung erfolgt zum Ende der bezahlten +Periode; der Kunde hat ab Bereitstellung 30 Tage Zeit, sie abzuholen.

+ +

10.2 Nach Ablauf dieser Frist löscht CluPilot die Instanz samt aller darin enthaltenen +personenbezogenen Daten sowie die zugehörigen Sicherungen. Die Löschung wird dem Kunden +auf Anfrage bestätigt.

+ +

10.3 Von der Löschung ausgenommen sind Daten, für die eine gesetzliche +Aufbewahrungspflicht besteht — insbesondere Rechnungen und Buchhaltungsunterlagen +(sieben Jahre, § 132 BAO). Diese Daten verarbeitet CluPilot als eigener Verantwortlicher.

+ +

11. Haftung

+ +

Es gilt Art. 82 DSGVO. Im Übrigen richtet sich die Haftung nach dem Hauptvertrag.

+ +

12. Schlussbestimmungen

+ +

12.1 Änderungen bedürfen der Textform. Widerspricht dieser Vertrag dem Hauptvertrag in +Fragen des Datenschutzes, geht dieser Vertrag vor.

+ +

12.2 Ist eine Bestimmung unwirksam, bleibt der übrige Vertrag gültig.

+ +

12.3 Es gilt österreichisches Recht. Gerichtsstand ist, soweit gesetzlich zulässig, +{{ $c['city'] ?? 'Wien' }}.

+ +

Anlage 1: Technische und organisatorische Maßnahmen (TOM), Fassung {{ $version }}.

diff --git a/resources/views/legal/dpa/measures.blade.php b/resources/views/legal/dpa/measures.blade.php new file mode 100644 index 0000000..7ab10ce --- /dev/null +++ b/resources/views/legal/dpa/measures.blade.php @@ -0,0 +1,148 @@ +{{-- + Anlage 1: Technische und organisatorische Maßnahmen (Art. 32 DSGVO). + + Written from what this installation ACTUALLY does — the backup job as + RegisterBackup creates it, the isolation Proxmox gives, the sign-in as + Fortify enforces it, the deletion deadlines the two prune commands run. Every + sentence here is meant to survive somebody checking it against the machine. + + Where a measure is planned but not in place, it is not written down. A TOM + that claims more than the system does is worse than a short one: it is the + document an auditor reads before looking. +--}} +@php + $c = App\Support\CompanyProfile::all(); +@endphp + +

Anlage 1 — Technische und organisatorische Maßnahmen

+

gemäß Art. 32 DSGVO, Anlage zum Auftragsverarbeitungsvertrag

+ +

Fassung {{ $version }} · Stand {{ $issuedOn }} · {{ $c['name'] ?? 'CluPilot Cloud e.U.' }}

+ +

Diese Anlage beschreibt die Maßnahmen, die CluPilot beim Betrieb der +Kundeninstanzen tatsächlich umsetzt. Sie wird mit jeder Fassung des +Auftragsverarbeitungsvertrags überprüft und, wo nötig, fortgeschrieben.

+ +

1. Zutrittskontrolle (physisch)

+ +

Die Instanzen laufen ausschließlich in Rechenzentren der in Ziffer 6 des +Hauptvertrags genannten Unterauftragsverarbeiter innerhalb der Europäischen Union. +Physischer Zutritt, Videoüberwachung, Zutrittsprotokollierung, Brand- und +Einbruchschutz sowie unterbrechungsfreie Stromversorgung liegen in deren +Verantwortung und richten sich nach deren jeweils veröffentlichten Nachweisen +(u. a. ISO/IEC 27001). CluPilot selbst betreibt keine eigenen Serverräume.

+ +

2. Zugangskontrolle (Systemzugang)

+ + + +

3. Zugriffskontrolle (Berechtigungen)

+ + + +

4. Trennungskontrolle (Mandantentrennung)

+ + + +

5. Weitergabekontrolle (Transport und Ablage)

+ + + +

6. Eingabekontrolle (Nachvollziehbarkeit)

+ + + +

7. Verfügbarkeit und Belastbarkeit

+ + + +

8. Löschung und Aufbewahrung

+ + + +

9. Auftragskontrolle und Überprüfung

+ + diff --git a/resources/views/livewire/admin/processing-agreements.blade.php b/resources/views/livewire/admin/processing-agreements.blade.php index e52bad8..26d86cd 100644 --- a/resources/views/livewire/admin/processing-agreements.blade.php +++ b/resources/views/livewire/admin/processing-agreements.blade.php @@ -129,6 +129,10 @@ {{ __('dpa_admin.file_measures') }} @endif + + {{ __('dpa_admin.download') }} + diff --git a/resources/views/livewire/settings.blade.php b/resources/views/livewire/settings.blade.php index 05242c8..6ae2ea9 100644 --- a/resources/views/livewire/settings.blade.php +++ b/resources/views/livewire/settings.blade.php @@ -376,6 +376,12 @@ {{ __('dpa.read_measures') }} @endif + {{-- Keep, not just read: the file carries the version in + its name, so "which fassung did I agree to" is + answerable from a downloads folder months later. --}} + + {{ __('dpa.download') }} + {{ __('dpa.version', ['version' => $dpa->version]) }} diff --git a/routes/admin.php b/routes/admin.php index b4b218c..4d0049d 100644 --- a/routes/admin.php +++ b/routes/admin.php @@ -71,12 +71,16 @@ Route::get('/processing-agreement/{uuid}/{which}', function (string $uuid, strin abort_if($path === null || ! Illuminate\Support\Facades\Storage::disk('local')->exists($path), 404); - // Inline: a contract is read before it is filed, and a download that opens - // nowhere is a download somebody has to go looking for. - return Illuminate\Support\Facades\Storage::disk('local')->response($path, null, [ - 'Content-Type' => 'application/pdf', - 'Content-Disposition' => 'inline', - ]); + // Inline: a contract is read before it is filed. With ?download=1 it is + // kept instead, under a name carrying the version. + $name = ($which === 'agreement' ? 'CluPilot-AV-Vertrag-' : 'CluPilot-TOM-').$version->version.'.pdf'; + + return request()->boolean('download') + ? Illuminate\Support\Facades\Storage::disk('local')->download($path, $name) + : Illuminate\Support\Facades\Storage::disk('local')->response($path, $name, [ + 'Content-Type' => 'application/pdf', + 'Content-Disposition' => 'inline; filename="'.$name.'"', + ]); })->name('dpa.file'); // The answers an operator gives over and over, written once — see diff --git a/routes/web.php b/routes/web.php index 1a2436a..006570e 100644 --- a/routes/web.php +++ b/routes/web.php @@ -317,10 +317,17 @@ $portal = function () { abort_if($path === null || ! Illuminate\Support\Facades\Storage::disk('local')->exists($path), 404); - return Illuminate\Support\Facades\Storage::disk('local')->response($path, null, [ - 'Content-Type' => 'application/pdf', - 'Content-Disposition' => 'inline', - ]); + // Inline to read, attachment to keep — and the file that lands in + // somebody's downloads folder carries the VERSION in its name, so + // "which one did I agree to" is answerable from the filename alone. + $name = ($which === 'agreement' ? 'CluPilot-AV-Vertrag-' : 'CluPilot-TOM-').$version->version.'.pdf'; + + return request()->boolean('download') + ? Illuminate\Support\Facades\Storage::disk('local')->download($path, $name) + : Illuminate\Support\Facades\Storage::disk('local')->response($path, $name, [ + 'Content-Type' => 'application/pdf', + 'Content-Disposition' => 'inline; filename="'.$name.'"', + ]); })->name('dpa.file'); Route::get('/billing', Billing::class)->name('billing'); diff --git a/tests/Feature/ProcessingAgreementTest.php b/tests/Feature/ProcessingAgreementTest.php index e978be3..c102d98 100644 --- a/tests/Feature/ProcessingAgreementTest.php +++ b/tests/Feature/ProcessingAgreementTest.php @@ -21,7 +21,9 @@ use Livewire\Livewire; * paper. The website already promises "AV-Vertrag inklusive", which means it has * to be obtainable without asking for it. * - * The TEXT is never this application's. It is uploaded. + * The TEXT comes from one of two places, and the console does not care which: + * generated from this repository (resources/views/legal/dpa, via + * clupilot:publish-dpa) or uploaded as a lawyer's revision. */ function dpaCustomer(): array { @@ -214,3 +216,74 @@ it('confirms publication in a modal rather than on one click', function () { // The modal decides nothing: the permission check stays where it was. ->and($modal)->not->toContain('published_at'); }); + +// ---- The documents this repository generates ---- + +it('renders both documents as real PDFs, from the company on record', function () { + // Generated rather than uploaded, so the agreement cannot name the company + // differently from the invoices — and a wording change is a diff somebody + // can review. + App\Support\CompanyProfile::put(['name' => 'Prüf GmbH', 'city' => 'Wien', 'country' => 'Österreich']); + + $renderer = app(App\Services\Legal\DpaRenderer::class); + + $agreement = $renderer->agreement('9.9'); + $measures = $renderer->measures('9.9'); + + expect(substr($agreement, 0, 4))->toBe('%PDF') + ->and(substr($measures, 0, 4))->toBe('%PDF') + ->and(strlen($agreement))->toBeGreaterThan(20000) + ->and(strlen($measures))->toBeGreaterThan(20000); +}); + +it('names every place customer data actually leaves this application', function () { + // A sub-processor list that quietly omits one is the single most common + // defect in a processing agreement — hosting, payment and mail are the three + // here, and each is named with where it processes. + $names = collect(App\Services\Legal\DpaRenderer::subprocessors())->pluck('name')->implode(' '); + + expect($names)->toContain('Hetzner') + ->and($names)->toContain('Stripe') + ->and(App\Services\Legal\DpaRenderer::subprocessors())->each->toHaveKeys(['name', 'service', 'location']); +}); + +it('publishes a generated version in one command, and refuses to reuse a name', function () { + Storage::fake('local'); + + $this->artisan('clupilot:publish-dpa', ['version' => '7.0'])->assertSuccessful(); + + $version = DpaVersion::query()->where('version', '7.0')->sole(); + + expect($version->isPublished())->toBeTrue() + ->and(Storage::disk('local')->exists($version->agreement_path))->toBeTrue() + ->and(Storage::disk('local')->exists($version->measures_path))->toBeTrue(); + + // Two documents under one name makes every acceptance ambiguous. + $this->artisan('clupilot:publish-dpa', ['version' => '7.0'])->assertFailed(); +}); + +it('keeps a drafted version out of force', function () { + Storage::fake('local'); + + $this->artisan('clupilot:publish-dpa', ['version' => '8.0', '--draft' => true])->assertSuccessful(); + + expect(DpaVersion::query()->where('version', '8.0')->sole()->isPublished())->toBeFalse() + ->and(app(ProcessingAgreement::class)->current())->toBeNull(); +}); + +it('hands the file over under a name carrying the version', function () { + // "Which fassung did I agree to" has to be answerable from a downloads + // folder months later, without opening anything. + publishedDpa('2.5'); + [$user] = dpaCustomer(); + + $this->actingAs($user) + ->get(route('dpa.file', ['which' => 'agreement', 'download' => 1])) + ->assertOk() + ->assertDownload('CluPilot-AV-Vertrag-2.5.pdf'); + + $this->actingAs($user) + ->get(route('dpa.file', ['which' => 'measures', 'download' => 1])) + ->assertOk() + ->assertDownload('CluPilot-TOM-2.5.pdf'); +}); diff --git a/tests/Feature/SiteDesignSystemTest.php b/tests/Feature/SiteDesignSystemTest.php index 1e25482..bb04acd 100644 --- a/tests/Feature/SiteDesignSystemTest.php +++ b/tests/Feature/SiteDesignSystemTest.php @@ -148,13 +148,17 @@ it('draws the wordmark from one definition, not five', function () { $source = preg_replace('/\{\{--.*?--\}\}/s', '', File::get($file->getPathname())); - // The wordmark is markup around the name; a plain sentence mentioning - // the company is not. `>Clu` with NO whitespace after the tag catches - // exactly the lockup forms — the name is the whole content of its - // element there. Whitespace used to be allowed, and then the terms page - // arrived with a paragraph whose first word is the company name, which - // is a sentence and not a brand lockup. - if (preg_match('/>Clu(Pilot|<)/', $source)) { + // The wordmark is markup AROUND the name: the name is the whole content + // of its element, so what follows it is the next tag and not more + // sentence. `>Clu<` is the split form, `>CluPilot` up to a tag is the + // whole one. + // + // Twice now a document has tripped a looser version of this — the terms + // page and then the processing agreement, both with a paragraph whose + // first word happens to be the company name. That is a sentence, not a + // brand lockup, and a scan that cannot tell them apart is a scan that + // gets edited every time somebody writes prose. + if (preg_match('/>Clu(<|Pilot\s*<)/', $source)) { $offenders[] = str_replace(resource_path().'/', '', $file->getPathname()); } }