create(['email_verified_at' => now()]); $customer = Customer::factory()->create(['email' => $user->email, 'user_id' => $user->id]); return [$user, $customer]; } function publishedDpa(string $version = '1.0', bool $withMeasures = true): DpaVersion { Storage::fake('local'); return DpaVersion::query()->create([ 'version' => $version, 'agreement_path' => UploadedFile::fake()->create('avv.pdf', 12, 'application/pdf')->store('dpa', 'local'), 'measures_path' => $withMeasures ? UploadedFile::fake()->create('tom.pdf', 12, 'application/pdf')->store('dpa', 'local') : null, 'published_at' => now()->subDay(), ]); } it('shows the customer nothing at all until a version is in force', function () { // A card offering an agreement that does not exist is worse than silence. [$user] = dpaCustomer(); Livewire::actingAs($user)->test(Settings::class, ['tab' => 'contract']) ->assertDontSee(__('dpa.title')); }); it('offers the document and records the acceptance with its evidence', function () { $version = publishedDpa(); [$user, $customer] = dpaCustomer(); $page = Livewire::actingAs($user)->test(Settings::class, ['tab' => 'contract']); $page->assertSee(__('dpa.title')) ->assertSee(__('dpa.state_open')) ->assertSee(__('dpa.accept_cta')) ->call('acceptProcessingAgreement') ->assertDispatched('notify'); $acceptance = DpaAcceptance::query()->where('customer_id', $customer->id)->sole(); // What makes it evidence rather than a flag: the version, the moment, the // login that pressed it, and where from. expect($acceptance->dpa_version_id)->toBe($version->id) ->and($acceptance->user_id)->toBe($user->id) ->and($acceptance->accepted_at)->not->toBeNull() ->and($acceptance->ip)->not->toBeNull(); // And the card now states it rather than asking again. Livewire::actingAs($user)->test(Settings::class, ['tab' => 'contract']) ->assertSee(__('dpa.state_accepted')) ->assertDontSee(__('dpa.accept_cta')); }); it('counts pressing twice as one agreement', function () { publishedDpa(); [$user, $customer] = dpaCustomer(); Livewire::actingAs($user)->test(Settings::class, ['tab' => 'contract']) ->call('acceptProcessingAgreement') ->call('acceptProcessingAgreement'); expect(DpaAcceptance::query()->where('customer_id', $customer->id)->count())->toBe(1); }); it('asks again when a new version comes into force, and keeps the old acceptance', function () { // Acceptance is per version: a new one supersedes the last, and accepting a // superseded document is not accepting the one in force. The old row stays — // it was true when it was made, and the history is the point. $first = publishedDpa('1.0'); [$user, $customer] = dpaCustomer(); app(ProcessingAgreement::class)->accept($customer); expect(app(ProcessingAgreement::class)->accepted($customer))->toBeTrue(); $second = DpaVersion::query()->create([ 'version' => '2.0', 'agreement_path' => $first->agreement_path, 'published_at' => now(), ]); expect(app(ProcessingAgreement::class)->current()->id)->toBe($second->id) ->and(app(ProcessingAgreement::class)->accepted($customer))->toBeFalse() // Not deleted, not overwritten. ->and(DpaAcceptance::query()->where('dpa_version_id', $first->id)->exists())->toBeTrue(); }); it('serves the document to the customer, and only the one in force', function () { publishedDpa(); [$user] = dpaCustomer(); $this->actingAs($user)->get(route('dpa.file', 'agreement'))->assertOk(); $this->actingAs($user)->get(route('dpa.file', 'measures'))->assertOk(); // Which document applies is ours to say, not a parameter somebody can put // in the address. $this->actingAs($user)->get(route('dpa.file', 'sonstwas'))->assertNotFound(); }); it('does not hand the agreement to a stranger', function () { publishedDpa(); $this->get(route('dpa.file', 'agreement'))->assertRedirect(); }); it('answers 404 rather than 500 when nothing is published', function () { [$user] = dpaCustomer(); $this->actingAs($user)->get(route('dpa.file', 'agreement'))->assertNotFound(); }); // ---- The console ---- it('takes an upload as a draft, and only a second act puts it in force', function () { // Publishing leaves every customer who accepted the previous version // outstanding again. That is correct, and expensive to trigger by dropping a // file on a form. Storage::fake('local'); Livewire::actingAs(operator('Owner'), 'operator') ->test(ProcessingAgreements::class) ->set('version', '3.0') ->set('agreement', UploadedFile::fake()->create('avv.pdf', 20, 'application/pdf')) ->call('upload') ->assertHasNoErrors(); $version = DpaVersion::query()->where('version', '3.0')->sole(); expect($version->isPublished())->toBeFalse() ->and(app(ProcessingAgreement::class)->current())->toBeNull() ->and(Storage::disk('local')->exists($version->agreement_path))->toBeTrue(); Livewire::actingAs(operator('Owner'), 'operator') ->test(ProcessingAgreements::class) ->call('publish', $version->uuid); expect(app(ProcessingAgreement::class)->current()?->id)->toBe($version->id); }); it('takes the agreement as a PDF and nothing else', function () { Storage::fake('local'); Livewire::actingAs(operator('Owner'), 'operator') ->test(ProcessingAgreements::class) ->set('version', '4.0') ->set('agreement', UploadedFile::fake()->create('avv.docx', 20, 'application/msword')) ->call('upload') ->assertHasErrors(['agreement']); }); it('refuses two versions under one name', function () { publishedDpa('1.0'); Storage::fake('local'); Livewire::actingAs(operator('Owner'), 'operator') ->test(ProcessingAgreements::class) ->set('version', '1.0') ->set('agreement', UploadedFile::fake()->create('avv.pdf', 20, 'application/pdf')) ->call('upload') ->assertHasErrors(['version']); }); it('keeps the whole thing to operators who may manage it', function () { // Its own capability: whoever keeps the platform running does not thereby // decide what every customer is asked to agree to. Livewire::actingAs(operator('Read-only'), 'operator') ->test(ProcessingAgreements::class) ->assertForbidden(); $this->actingAs(operator('Read-only'), 'operator') ->get(route('admin.dpa')) ->assertForbidden(); }); it('confirms publication in a modal rather than on one click', function () { // R23, and for a reason beyond the rule: this is the click that makes every // customer outstanding again. $page = Illuminate\Support\Facades\File::get(resource_path('views/livewire/admin/processing-agreements.blade.php')); expect($page)->toContain("component: 'admin.confirm-publish-dpa'"); $modal = Illuminate\Support\Facades\File::get(app_path('Livewire/Admin/ConfirmPublishDpa.php')); expect($modal)->toContain("dispatch('dpa-publish-confirmed'") // 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'); }); it('leaves the documents readable by the process that has to serve them', function () { // The failure this prevents had no error message anywhere: an artisan run in // a container is root, PHP-FPM is www-data, and Laravel's local disk creates // a private directory as 0700. The web process could not enter it, the // route's exists() check answered false, and every link on the page 404'd // while the file sat there perfectly intact. // // "public" here is the file MODE and nothing to do with the web: this disk's // root is outside the document root either way. Storage::fake('local'); $this->artisan('clupilot:publish-dpa', ['version' => '6.1'])->assertSuccessful(); $version = DpaVersion::query()->where('version', '6.1')->sole(); expect(Storage::disk('local')->getVisibility($version->agreement_path))->toBe('public') ->and(Storage::disk('local')->getVisibility($version->measures_path))->toBe('public'); }); it('serves the console download too, not only the portal one', function () { publishedDpa('3.3'); $version = DpaVersion::query()->where('version', '3.3')->sole(); $this->actingAs(operator('Owner'), 'operator') ->get(route('admin.dpa.file', ['uuid' => $version->uuid, 'which' => 'agreement', 'download' => 1])) ->assertOk() ->assertDownload('CluPilot-AV-Vertrag-3.3.pdf'); });