CluPilotCloud/tests/Feature/SeatsTest.php

323 lines
15 KiB
PHP

<?php
use App\Livewire\ConfirmRevokeSeat;
use App\Livewire\EditSeat;
use App\Livewire\Users;
use App\Models\Customer;
use App\Models\Instance;
use App\Models\Order;
use App\Models\Seat;
use App\Models\User;
use Livewire\Livewire;
function seatSetup(string $plan = 'team'): array
{
$user = User::factory()->create(['email' => 'o@seat.test', 'is_admin' => false]);
$customer = Customer::factory()->create(['email' => 'o@seat.test', 'user_id' => $user->id, 'name' => 'Owner Co']);
$order = Order::factory()->create(['customer_id' => $customer->id, 'plan' => $plan]);
Instance::factory()->create(['customer_id' => $customer->id, 'order_id' => $order->id, 'plan' => $plan, 'status' => 'active']);
return compact('user', 'customer');
}
it('creates an owner seat on first visit', function () {
['user' => $user, 'customer' => $customer] = seatSetup();
Livewire::actingAs($user)->test(Users::class)->assertOk();
$owner = $customer->seats()->where('role', 'owner')->first();
expect($owner)->not->toBeNull()->and($owner->email)->toBe('o@seat.test');
});
// invite() heisst seit dem Nextcloud-Anschluss addSeat(), weil sie genau das
// tut und nichts weiter: eine Zeile anlegen. Verschickt wird erst in
// sendInvite() — siehe SyncSeatToNextcloudTest. Die Sitzplatzgrenze und die
// Sperre gegen doppelte Adressen sind dabei unverändert geblieben.
it('adds a seat and blocks duplicates', function () {
['user' => $user, 'customer' => $customer] = seatSetup();
Livewire::actingAs($user)->test(Users::class)
->set('inviteEmail', 'new@seat.test')->set('inviteRole', 'member')->call('addSeat')
->assertHasNoErrors();
expect($customer->seats()->where('email', 'new@seat.test')->exists())->toBeTrue();
Livewire::actingAs($user)->test(Users::class)
->set('inviteEmail', 'new@seat.test')->call('addSeat')
->assertHasErrors(['inviteEmail']);
expect($customer->seats()->where('email', 'new@seat.test')->count())->toBe(1);
});
it('enforces the plan seat limit', function () {
['user' => $user, 'customer' => $customer] = seatSetup('start'); // 5-seat plan
// Fill all 5 seats so the plan allowance is exhausted.
Seat::factory()->count(5)->create(['customer_id' => $customer->id]);
Livewire::actingAs($user)->test(Users::class)
->set('inviteEmail', 'over@seat.test')->call('addSeat')
->assertHasErrors(['inviteEmail']);
expect($customer->seats()->where('email', 'over@seat.test')->exists())->toBeFalse();
});
it('will not remove or demote the last owner', function () {
['user' => $user, 'customer' => $customer] = seatSetup();
Livewire::actingAs($user)->test(Users::class); // creates owner
$owner = $customer->seats()->where('role', 'owner')->first();
// Drei Handlungen, drei eigene Meldungen (users.owner_locked trug bis vor
// kurzem alle drei) — je Knopf ein eigener Satz, der sagt, was HIER
// gerade nicht geht.
Livewire::actingAs($user)->test(Users::class)->call('revoke', $owner->uuid)
->assertDispatched('notify', message: __('users.owner_revoke_locked'));
// Auf den STATUS geprüft, nicht mehr nur auf die Zeile: seit revoke()
// grundsätzlich nicht mehr löscht, bewiese eine noch vorhandene Zeile
// gar nichts — sie bliebe auch dann stehen, wenn die Inhaber-Sperre
// versagte. Der Status ist die Zusicherung, die hier gemeint war.
expect($customer->seats()->whereKey($owner->id)->exists())->toBeTrue()
->and($owner->fresh()->status)->toBe('active');
Livewire::actingAs($user)->test(Users::class)->call('setRole', $owner->uuid, 'member')
->assertDispatched('notify', message: __('users.owner_role_locked'));
expect($owner->fresh()->role)->toBe('owner');
Livewire::actingAs($user)->test(Users::class)->call('suspend', $owner->uuid)
->assertDispatched('notify', message: __('users.owner_locked'));
expect($owner->fresh()->status)->toBe('active');
});
it('revokes a non-owner seat without deleting the row', function () {
// Früher wurde die Zeile gelöscht. Das fällt weg: ein Fehlgriff im
// Userpanel darf die Arbeit eines Menschen nicht vernichten, und in einem
// Produkt, das mit Nachvollziehbarkeit verkauft wird, gäbe es danach
// nichts mehr zu zeigen, wer welchen Zugang hatte. Gelöscht wird, wenn
// überhaupt, in der Nextcloud selbst — dort fragt Nextcloud nach, was mit
// den Dateien geschehen soll.
['user' => $user, 'customer' => $customer] = seatSetup();
$seat = Seat::factory()->create(['customer_id' => $customer->id, 'role' => 'member']);
Livewire::actingAs($user)->test(Users::class)->call('revoke', $seat->uuid);
expect($customer->seats()->whereKey($seat->id)->exists())->toBeTrue()
->and($seat->fresh()->status)->toBe('revoked');
});
it('shows the actions column even when the owner is the only seat', function () {
// Reported three times: a customer with one seat saw a table with nothing
// to click and concluded the product could not edit users. The column used
// to be hidden when there was "nothing to act on" — but a column that
// disappears does not read as "not applicable here", it reads as missing.
$user = User::factory()->create();
$customer = Customer::factory()->create(['user_id' => $user->id, 'email' => $user->email]);
Livewire::actingAs($user)
->test(Users::class)
->assertSee(__('users.col_actions'))
->assertSee(__('users.edit'))
// And it says why the owner has no destructive actions instead of
// leaving an empty cell.
->assertSee(__('users.owner_protected'));
expect($customer->seats()->count())->toBe(1);
});
it('renames any seat, the owner included', function () {
$user = User::factory()->create();
$customer = Customer::factory()->create(['user_id' => $user->id, 'email' => $user->email]);
$owner = $customer->seats()->create([
'email' => $user->email, 'name' => 'Falsch Geschrieben', 'role' => 'owner', 'status' => 'active',
]);
Livewire::actingAs($user)
->test(EditSeat::class, ['uuid' => $owner->uuid])
->assertSet('name', 'Falsch Geschrieben')
->assertSet('isOwner', true)
->set('name', 'Dr. M. Muster')
->call('save')
->assertHasNoErrors();
expect($owner->fresh()->name)->toBe('Dr. M. Muster');
});
it('corrects the address of an invitation nobody has sent yet', function () {
// Angelegt, noch nicht eingeladen: in der Cloud gibt es dazu nichts, und
// ein Tippfehler ist an dieser Stelle folgenlos zu berichtigen.
$user = User::factory()->create();
$customer = Customer::factory()->create(['user_id' => $user->id, 'email' => $user->email]);
$seat = $customer->seats()->create([
'email' => 'tpyo@example.test', 'role' => 'member', 'status' => 'invited',
]);
Livewire::actingAs($user)
->test(EditSeat::class, ['uuid' => $seat->uuid])
->assertSet('addressEditable', true)
->set('email', 'typo@example.test')
->call('save')
->assertHasNoErrors();
expect($seat->fresh()->email)->toBe('typo@example.test');
});
it('will not move a seat that already has an account in the cloud', function () {
// Die Bedingung hing an `status === 'invited'` — und NICHTS im Bestand
// setzt `seats.status` je von 'invited' auf 'active'. Ein eingeladener
// Sitz blieb damit für immer 'invited', und die Adresse dauerhaft
// änderbar: `seats.email` wurde `b@y`, `nc_username` blieb `a@x`
// (richtig — Nextcloud kann nicht umbenennen), und kein Befehl trug die
// neue Adresse in den Gast. „Erneut senden" schickte danach
// `user:welcome --reset-password 'a@x'`: das Portal führte `b@y` als
// Inhaberin des Sitzes, tatsächlich hielt `a@x` den Zugang und bekam
// jederzeit einen neuen Zurücksetzen-Link.
//
// Massgeblich ist deshalb `nc_username`: ab da IST die Adresse die Person.
// Am Formular vorbei gefahren, mit Absicht — die Eigenschaft kommt aus
// dem Browser und darf nicht geglaubt werden.
$user = User::factory()->create();
$customer = Customer::factory()->create(['user_id' => $user->id, 'email' => $user->email]);
$seat = $customer->seats()->create([
'email' => 'in@example.test', 'name' => 'A', 'role' => 'member', 'status' => 'invited',
'nc_username' => 'in@example.test',
]);
Livewire::actingAs($user)
->test(EditSeat::class, ['uuid' => $seat->uuid])
->assertSet('addressEditable', false)
->set('addressEditable', true)
->set('email', 'someone.else@example.test')
->set('name', 'B')
->call('save');
expect($seat->fresh()->email)->toBe('in@example.test')
// Umbenennen bleibt erlaubt — es ist dieselbe Person.
->and($seat->fresh()->name)->toBe('B');
});
it('will not move the owner seat to a different address', function () {
// Der Inhaber-Sitz steht immer auf 'active' — an `status === 'invited'` war
// seine Adresse deshalb NIE änderbar. An `nc_username` allein wäre sie es,
// solange `linkToInstanceAdmin()` noch nicht gegriffen hat, also genau vor
// der Bereitstellung.
//
// Was dabei entstünde, ist kein Umbenennen: läuft die Adresse des
// Inhaber-Sitzes von der Kundenadresse weg, legt „Anlegen" mit der echten
// Adresse eine ZWEITE Zeile für dieselbe Person an, die gegen die
// Platzgrenze zählt.
$user = User::factory()->create();
$customer = Customer::factory()->create(['user_id' => $user->id, 'email' => $user->email]);
// Ohne nc_username: die Bereitstellung hat das Admin-Konto noch nicht
// angelegt, der Sitz ist also noch mit nichts im Gast verknüpft.
$owner = $customer->seats()->create([
'email' => $user->email, 'name' => 'Inhaberin', 'role' => 'owner', 'status' => 'active',
]);
Livewire::actingAs($user)
->test(EditSeat::class, ['uuid' => $owner->uuid])
->assertSet('addressEditable', false)
->set('addressEditable', true)
->set('email', 'privat@woanders.test')
->set('name', 'Dr. Inhaberin')
->call('save');
expect($owner->fresh()->email)->toBe($user->email)
// Umbenennen bleibt erlaubt, auch am Inhaber-Sitz.
->and($owner->fresh()->name)->toBe('Dr. Inhaberin');
});
it('refuses to open a seat belonging to somebody else', function () {
// The uuid comes from the browser, and a modal is reachable without the
// page's route middleware.
$mine = User::factory()->create();
Customer::factory()->create(['user_id' => $mine->id, 'email' => $mine->email]);
$theirs = Customer::factory()->create(['user_id' => User::factory()->create()->id]);
$seat = $theirs->seats()->create(['email' => 'x@example.test', 'name' => 'X', 'role' => 'member', 'status' => 'active']);
Livewire::actingAs($mine)
->test(EditSeat::class, ['uuid' => $seat->uuid])
->assertStatus(404);
expect($seat->fresh()->name)->toBe('X');
});
it('opens editing in a modal rather than in the row', function () {
// R20. Inline fields made the row grow and the columns beside it jump,
// which reads as a rendering fault rather than as a form.
$user = User::factory()->create();
Customer::factory()->create(['user_id' => $user->id, 'email' => $user->email]);
Livewire::actingAs($user)
->test(Users::class)
->assertSee('openModal', escape: false)
->assertSee('edit-seat', escape: false)
->assertDontSee('wire:model="editName"', escape: false);
});
// R23: confirmation moved from wire:confirm to ConfirmRevokeSeat, which
// mirrors EditSeat above (a modal is reachable without the page's route
// middleware, so the seat is resolved fresh from the signed-in customer) but
// dispatches back to Users::revoke() instead of mutating anything itself.
it('opens the revoke confirmation in a modal too', function () {
// The revoke button only renders for a non-owner seat (the owner row has
// no destructive actions at all), so one has to exist first.
['user' => $user, 'customer' => $customer] = seatSetup();
Seat::factory()->create(['customer_id' => $customer->id, 'role' => 'member']);
Livewire::actingAs($user)
->test(Users::class)
->assertSee('confirm-revoke-seat', escape: false);
});
it('refuses to open the revoke confirmation for the owner seat', function () {
['user' => $user, 'customer' => $customer] = seatSetup();
Livewire::actingAs($user)->test(Users::class); // creates owner
$owner = $customer->seats()->where('role', 'owner')->first();
Livewire::actingAs($user)
->test(ConfirmRevokeSeat::class, ['uuid' => $owner->uuid])
->assertStatus(404);
expect($customer->seats()->whereKey($owner->id)->exists())->toBeTrue();
});
it('refuses to open the revoke confirmation for somebody else\'s seat', function () {
$mine = User::factory()->create();
Customer::factory()->create(['user_id' => $mine->id, 'email' => $mine->email]);
$theirs = Customer::factory()->create(['user_id' => User::factory()->create()->id]);
$seat = $theirs->seats()->create(['email' => 'x@example.test', 'name' => 'X', 'role' => 'member', 'status' => 'active']);
Livewire::actingAs($mine)
->test(ConfirmRevokeSeat::class, ['uuid' => $seat->uuid])
->assertStatus(404);
expect($seat->fresh()->name)->toBe('X');
});
it('confirms a revoke through the modal without removing the seat itself', function () {
['user' => $user, 'customer' => $customer] = seatSetup();
$seat = Seat::factory()->create(['customer_id' => $customer->id, 'role' => 'member', 'name' => 'Weg Damit']);
Livewire::actingAs($user)
->test(ConfirmRevokeSeat::class, ['uuid' => $seat->uuid])
->assertSee('Weg Damit')
->call('confirm')
->assertDispatched('seat-revoke-confirmed', uuid: $seat->uuid);
expect($customer->seats()->whereKey($seat->id)->exists())->toBeTrue();
});
it('revokes the seat once the page receives the confirmed event', function () {
// Exercises the forwarding listener directly — see the equivalent note in
// SecretsPageTest for why the cross-component event round trip itself is
// checked manually rather than in a single-component Livewire::test().
['user' => $user, 'customer' => $customer] = seatSetup();
$seat = Seat::factory()->create(['customer_id' => $customer->id, 'role' => 'member']);
Livewire::actingAs($user)->test(Users::class)->call('onRevokeConfirmed', $seat->uuid);
// Wieder der Status statt der Existenz: revoke() löscht nicht mehr (siehe
// die Notiz weiter oben), also wäre „die Zeile ist weg" jetzt eine
// Zusicherung, die das Produkt bewusst nicht mehr gibt.
expect($seat->fresh()->status)->toBe('revoked');
});