319 lines
13 KiB
PHP
319 lines
13 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']);
|
|
|
|
$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');
|
|
});
|