CluPilotCloud/tests/Feature/ProcessingAgreementTest.php

346 lines
14 KiB
PHP

<?php
use App\Livewire\Admin\ProcessingAgreements;
use App\Livewire\Settings;
use App\Models\Customer;
use App\Models\DpaAcceptance;
use App\Models\DpaVersion;
use App\Models\User;
use App\Services\Legal\ProcessingAgreement;
use Illuminate\Http\UploadedFile;
use Illuminate\Support\Facades\Storage;
use Livewire\Livewire;
/**
* The processing agreement, and the proof a customer has it.
*
* Art. 28(3) GDPR wants a contract wherever personal data is processed on
* somebody else's behalf, which is the whole of what this product does. "In
* writing" there includes electronic form (Art. 28(9)), so a document the
* customer can read plus a recorded acceptance is enough — no signature on
* paper. The website already promises "AV-Vertrag inklusive", which means it has
* to be obtainable without asking for it.
*
* 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
{
$user = User::factory()->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']);
// The agreement is concluded WITH the terms at checkout, so the page states
// that rather than nagging — and offers the extra confirmation for whoever
// wants one on file.
$page->assertSee(__('dpa.title'))
->assertSee(__('dpa.in_force_note'))
->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 states the confirmation rather than asking again.
Livewire::actingAs($user)->test(Settings::class, ['tab' => 'contract'])
->assertSee($acceptance->accepted_at->local()->isoFormat('LL, LT'))
->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');
});
it('never treats the agreement as something outstanding', function () {
// Art. 28 wants a contract, not a ceremony: it is concluded with the terms
// at checkout (see the AGB), so the page must not imply that a customer who
// has not clicked is missing something. Hosters that ask for a separate
// signature are the exception, not the rule.
publishedDpa();
[$user] = dpaCustomer();
$page = Livewire::actingAs($user)->test(Settings::class, ['tab' => 'contract'])->html();
expect($page)->toContain(__('dpa.in_force_note'))
// No warning colour, no "open" chip: the state is "available", and it is.
->and($page)->not->toContain('text-warning">'.__('dpa.title'));
});
it('says in the terms that the agreement comes with them', function () {
// Which is what makes the click optional rather than a gap.
$terms = $this->get(route('legal.agb'))->assertOk()->getContent();
expect($terms)->toContain('Auftragsverarbeitung')
->and($terms)->toContain('gesonderten Unterzeichnung')
->and($terms)->toContain('Kundenbereich');
});