Let a datacenter code be corrected while nothing depends on it, and say which building

The code was immutable, which made a typo permanent. It cannot be freely mutable
either, and not only because hosts.datacenter references it: each host's DNS name
was minted from it and registered at the provider, the machine's own hostname was
set from it as it was built, the counter handing out those numbers is keyed by
it, and every past order stores it as plain text with no foreign key to follow.
Renaming a code with hosts in it is not a rename — it is a datacenter called hel
full of machines called fsn-01.

So it is editable exactly while nothing references it, which is the case that
matters: a typo noticed shortly after creating one. Once a host or an order
depends on it the field is disabled and says what is holding it, with the
numbers. The lock is recomputed from the database on every save, never read back
from the property the browser returns.

Alongside it, an optional free-text field for the specific datacenter —
fsn-dc-15. A provider runs several within one location, on separate power and
separate uplinks, and two entries both reading "Falkenstein (fsn)" are not a
choice anybody can make. Nothing keys off it; the code remains the identifier.

Also renames the country loop variables in the edit modal. They were $code and
$name — the component's own properties — and shadowed them for the rest of the
file. Harmless while nothing below the loop read them, which stopped being true
the moment the code became an editable field above it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
feat/granted-plans
nexxo 2026-07-28 22:25:21 +02:00
parent 07634c8a1d
commit d65ab71029
10 changed files with 344 additions and 10 deletions

View File

@ -17,6 +17,16 @@ class Datacenters extends Component
#[Validate('required|string|max:255')] #[Validate('required|string|max:255')]
public string $name = ''; public string $name = '';
/**
* Which building, not which city fsn-dc-15. Optional, free text, and
* nothing keys off it: see EditDatacenter and the migration that adds it.
*
* Offered here as well as in the edit modal so that creating a datacenter
* and saying which one it is are not two separate errands.
*/
#[Validate('nullable|string|max:255')]
public string $facility = '';
// Country picked from config/countries.php (no manual code typos). // Country picked from config/countries.php (no manual code typos).
#[Validate('nullable|string|max:2')] #[Validate('nullable|string|max:2')]
public string $location = ''; public string $location = '';
@ -36,17 +46,19 @@ class Datacenters extends Component
// trailing dash. // trailing dash.
'code' => ['required', 'string', 'max:8', 'regex:/^[a-z0-9]([a-z0-9-]{0,6}[a-z0-9])?$/', 'unique:datacenters,code'], 'code' => ['required', 'string', 'max:8', 'regex:/^[a-z0-9]([a-z0-9-]{0,6}[a-z0-9])?$/', 'unique:datacenters,code'],
'name' => 'required|string|max:255', 'name' => 'required|string|max:255',
'facility' => 'nullable|string|max:255',
'location' => ['nullable', Rule::in(array_keys((array) config('countries')))], 'location' => ['nullable', Rule::in(array_keys((array) config('countries')))],
]); ]);
Datacenter::create([ Datacenter::create([
'code' => $data['code'], 'code' => $data['code'],
'name' => $data['name'], 'name' => $data['name'],
'facility' => $data['facility'] ?: null,
'location' => $data['location'] ?: null, 'location' => $data['location'] ?: null,
'active' => true, 'active' => true,
]); ]);
$this->reset('code', 'name', 'location'); $this->reset('code', 'name', 'facility', 'location');
$this->dispatch('notify', message: __('datacenters.created')); $this->dispatch('notify', message: __('datacenters.created'));
} }

View File

@ -3,14 +3,31 @@
namespace App\Livewire\Admin; namespace App\Livewire\Admin;
use App\Models\Datacenter; use App\Models\Datacenter;
use App\Models\Host;
use App\Models\Order;
use Illuminate\Validation\Rule; use Illuminate\Validation\Rule;
use Livewire\Attributes\Validate; use Livewire\Attributes\Validate;
use LivewireUI\Modal\ModalComponent; use LivewireUI\Modal\ModalComponent;
/** /**
* Edit a datacenter's name and location in a modal (avoids the row-height jump * Edit a datacenter in a modal (avoids the row-height jump of inline editing).
* of inline editing). The code is immutable it is referenced by hosts. Country * Country is picked from a list so it can't be mistyped.
* is picked from a list so it can't be mistyped. *
* The code is editable, but only while nothing depends on it.
*
* It was fully immutable, which made a typo permanent. It cannot be freely
* mutable either, because it is not only a foreign key: hosts.datacenter
* references it (and MariaDB refuses the update outright, there being no ON
* UPDATE clause), every past order stores it as plain text, each host's DNS
* name was minted from it (fsn-01 a record that exists at the provider and
* does not move), the machine's own hostname was set from it while it was being
* built, and the per-code counter that hands out those numbers is keyed by it.
* Renaming a code with hosts in it is therefore not a rename; it is a datacenter
* called hel full of machines called fsn-01.
*
* So: free while nothing references it which is the case that matters, a
* typo noticed shortly after creating one and locked with the reason named
* once anything does.
*/ */
class EditDatacenter extends ModalComponent class EditDatacenter extends ModalComponent
{ {
@ -21,6 +38,16 @@ class EditDatacenter extends ModalComponent
#[Validate('required|string|max:255')] #[Validate('required|string|max:255')]
public string $name = ''; public string $name = '';
/**
* Which building, not which city fsn-dc-15.
*
* The provider's own label, free text on purpose: it is theirs to change,
* and nothing here may key off it. Only so that picking "fsn" twice for
* redundancy is a decision somebody can actually make.
*/
#[Validate('nullable|string|max:255')]
public string $facility = '';
public string $location = ''; public string $location = '';
/** The location present when the modal opened — a pre-curation free-form value. */ /** The location present when the modal opened — a pre-curation free-form value. */
@ -35,6 +62,11 @@ class EditDatacenter extends ModalComponent
*/ */
public bool $active = true; public bool $active = true;
/** What references this code, and therefore what stops it being renamed. */
public int $hostCount = 0;
public int $orderCount = 0;
public function mount(string $uuid): void public function mount(string $uuid): void
{ {
$this->authorize('datacenters.manage'); // modals are reachable without the route middleware $this->authorize('datacenters.manage'); // modals are reachable without the route middleware
@ -42,9 +74,34 @@ class EditDatacenter extends ModalComponent
$this->uuid = $uuid; $this->uuid = $uuid;
$this->code = $dc->code; $this->code = $dc->code;
$this->name = $dc->name; $this->name = $dc->name;
$this->facility = (string) $dc->facility;
$this->location = (string) $dc->location; $this->location = (string) $dc->location;
$this->originalLocation = (string) $dc->location; $this->originalLocation = (string) $dc->location;
$this->active = (bool) $dc->active; $this->active = (bool) $dc->active;
$this->hostCount = $this->hostsIn($dc->code);
$this->orderCount = $this->ordersIn($dc->code);
}
/**
* May this code still be changed?
*
* Always answered from the database, never from a hydrated property: the
* lock is an authorisation decision, and a forged request that flipped it
* would take the DNS names of every host in this datacenter with it.
*/
public function codeIsFree(string $code): bool
{
return $this->hostsIn($code) === 0 && $this->ordersIn($code) === 0;
}
private function hostsIn(string $code): int
{
return Host::query()->where('datacenter', $code)->count();
}
private function ordersIn(string $code): int
{
return Order::query()->where('datacenter', $code)->count();
} }
public function save() public function save()
@ -60,11 +117,42 @@ class EditDatacenter extends ModalComponent
if ($dc->location) { if ($dc->location) {
$allowed[] = $dc->location; $allowed[] = $dc->location;
} }
$data = $this->validate([ // Recomputed here, never taken from the property the browser sent back:
// the lock is what protects every host's DNS name in this datacenter
// from being orphaned, and a forged request must not be able to lift it.
$codeIsFree = $this->codeIsFree($dc->code);
if ($codeIsFree) {
// Normalised before validating so the unique check matches how the
// row is stored — otherwise `FSN` passes the rule and the update
// then collides with an existing lowercased row (mirrors Datacenters).
$this->code = strtolower(trim($this->code));
} else {
// Whatever came back from the browser is discarded. Silently, and
// deliberately: the field was never offered, so there is nothing to
// report to an honest client, and a dishonest one gets no purchase.
$this->code = $dc->code;
}
$rules = [
'name' => 'required|string|max:255', 'name' => 'required|string|max:255',
'facility' => 'nullable|string|max:255',
'location' => ['nullable', Rule::in($allowed)], // array form — a legacy value may contain commas 'location' => ['nullable', Rule::in($allowed)], // array form — a legacy value may contain commas
'active' => 'boolean', 'active' => 'boolean',
]); ];
if ($codeIsFree) {
// The same rule the create form uses: a code becomes a DNS label
// (fsn-01.node.clupilot.com), so it has to be one. Ignoring its own
// row, or saving without touching the code would collide with itself.
$rules['code'] = [
'required', 'string', 'max:8',
'regex:/^[a-z0-9]([a-z0-9-]{0,6}[a-z0-9])?$/',
Rule::unique('datacenters', 'code')->ignore($dc->id),
];
}
$data = $this->validate($rules);
// Only on the transition, and compared against what is STORED: an // Only on the transition, and compared against what is STORED: an
// already-inactive location would otherwise announce that it had just // already-inactive location would otherwise announce that it had just
@ -73,7 +161,13 @@ class EditDatacenter extends ModalComponent
$hostsLeftRunning = $justDeactivated ? $dc->hosts()->count() : 0; $hostsLeftRunning = $justDeactivated ? $dc->hosts()->count() : 0;
Datacenter::query()->where('uuid', $this->uuid)->update([ Datacenter::query()->where('uuid', $this->uuid)->update([
// Only where it was actually offered. $data has no 'code' key at
// all when the field was locked, and writing $this->code back would
// be the same value anyway — but only by luck, and this is the line
// that would quietly stop being true if the lock ever moved.
...($codeIsFree ? ['code' => $data['code']] : []),
'name' => $data['name'], 'name' => $data['name'],
'facility' => $data['facility'] ?: null,
'location' => $data['location'] ?: null, 'location' => $data['location'] ?: null,
'active' => $data['active'], 'active' => $data['active'],
]); ]);
@ -90,8 +184,17 @@ class EditDatacenter extends ModalComponent
public function render() public function render()
{ {
// Read again rather than shown from the mounted properties: a host can
// be onboarded into this datacenter while the modal sits open, and an
// input offered on the strength of a stale count would be refused on
// save with nothing on screen explaining why.
$stored = Datacenter::query()->where('uuid', $this->uuid)->value('code') ?? $this->code;
$this->hostCount = $this->hostsIn($stored);
$this->orderCount = $this->ordersIn($stored);
return view('livewire.admin.edit-datacenter', [ return view('livewire.admin.edit-datacenter', [
'countries' => config('countries'), 'countries' => config('countries'),
'codeFree' => $this->hostCount === 0 && $this->orderCount === 0,
]); ]);
} }
} }

View File

@ -12,7 +12,7 @@ class Datacenter extends Model
/** @use HasFactory<\Database\Factories\DatacenterFactory> */ /** @use HasFactory<\Database\Factories\DatacenterFactory> */
use HasFactory, HasUuid; use HasFactory, HasUuid;
protected $fillable = ['code', 'name', 'location', 'active']; protected $fillable = ['code', 'name', 'facility', 'location', 'active'];
protected function casts(): array protected function casts(): array
{ {

View File

@ -0,0 +1,35 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
/**
* Which building, not which city.
*
* `code` and `name` say Falkenstein. They do not say WHICH datacenter in
* Falkenstein and a provider runs several within one location (fsn-dc-15,
* fsn-dc-18), on separate power and separate uplinks. Two hosts placed "in fsn"
* may sit in the same hall or ten minutes apart, which is the whole question
* anyone asks when placing a second machine for redundancy.
*
* Deliberately free text and deliberately optional: it is the provider's label,
* it changes when they rename their halls, and nothing in the system is allowed
* to key off it. `code` remains the identifier this only has to be readable.
*/
return new class extends Migration
{
public function up(): void
{
Schema::table('datacenters', function (Blueprint $table) {
$table->string('facility')->nullable()->after('name');
});
}
public function down(): void
{
Schema::table('datacenters', function (Blueprint $table) {
$table->dropColumn('facility');
});
}
};

View File

@ -6,7 +6,15 @@ return [
'empty' => 'Noch keine Rechenzentren. Lege dein erstes an.', 'empty' => 'Noch keine Rechenzentren. Lege dein erstes an.',
'code' => 'Kürzel', 'code' => 'Kürzel',
'code_hint' => 'Kurz & eindeutig, z. B. fsn, hel, nbg.', 'code_hint' => 'Kurz & eindeutig, z. B. fsn, hel, nbg.',
// Warum das Feld gesperrt ist, mit den Zahlen die es sperren. Das Kürzel
// steckt in den DNS-Namen der Hosts (fsn-01) und im Hostnamen der Maschine
// selbst — eine Umbenennung zieht dort nicht nach.
'code_locked' => 'Nicht mehr änderbar: :hosts Host(s) und :orders Bestellung(en) hängen daran. Das Kürzel steckt in ihren DNS-Namen.',
'name' => 'Name', 'name' => 'Name',
// Nicht welche Stadt, sondern welches Haus. Optional und frei — es ist die
// Bezeichnung des Anbieters, und nichts im System hängt daran.
'facility' => 'Konkretes Rechenzentrum',
'facility_hint' => 'Optional, z. B. fsn-dc-15 — damit bei der Auswahl erkennbar ist, welches gemeint ist.',
'location' => 'Standort', 'location' => 'Standort',
'location_hint' => 'Optional, z. B. DE, Falkenstein.', 'location_hint' => 'Optional, z. B. DE, Falkenstein.',
'hosts' => 'Hosts', 'hosts' => 'Hosts',

View File

@ -6,7 +6,15 @@ return [
'empty' => 'No datacenters yet. Add your first one.', 'empty' => 'No datacenters yet. Add your first one.',
'code' => 'Code', 'code' => 'Code',
'code_hint' => 'Short & unique, e.g. fsn, hel, nbg.', 'code_hint' => 'Short & unique, e.g. fsn, hel, nbg.',
// Why the field is locked, with the numbers that lock it. The code is baked
// into each host's DNS name (fsn-01) and into the machine's own hostname —
// a rename does not follow it there.
'code_locked' => 'No longer editable: :hosts host(s) and :orders order(s) depend on it. The code is part of their DNS names.',
'name' => 'Name', 'name' => 'Name',
// Which building, not which city. Optional and free text — it is the
// provider's label, and nothing in the system keys off it.
'facility' => 'Specific datacenter',
'facility_hint' => 'Optional, e.g. fsn-dc-15 — so the picker shows which one is meant.',
'location' => 'Location', 'location' => 'Location',
'location_hint' => 'Optional, e.g. DE, Falkenstein.', 'location_hint' => 'Optional, e.g. DE, Falkenstein.',
'hosts' => 'Hosts', 'hosts' => 'Hosts',

View File

@ -26,6 +26,10 @@
<td class="px-4 py-3 font-mono font-semibold text-ink">{{ $dc->code }}</td> <td class="px-4 py-3 font-mono font-semibold text-ink">{{ $dc->code }}</td>
<td class="px-4 py-3 text-body">{{ $dc->name }} <td class="px-4 py-3 text-body">{{ $dc->name }}
@if ($dc->location)<span class="text-xs text-muted">· {{ $countries[$dc->location] ?? $dc->location }}</span>@endif @if ($dc->location)<span class="text-xs text-muted">· {{ $countries[$dc->location] ?? $dc->location }}</span>@endif
{{-- Which building. Its own line, because it is
the answer to "which of the two in fsn is
this" and reads as noise beside the country. --}}
@if ($dc->facility)<span class="mt-0.5 block font-mono text-xs text-muted">{{ $dc->facility }}</span>@endif
</td> </td>
<td class="px-4 py-3 font-mono text-xs text-muted">{{ $dc->hosts_count }}</td> <td class="px-4 py-3 font-mono text-xs text-muted">{{ $dc->hosts_count }}</td>
<td class="px-4 py-3"> <td class="px-4 py-3">
@ -62,6 +66,8 @@
<h2 class="font-semibold text-ink">{{ __('datacenters.add') }}</h2> <h2 class="font-semibold text-ink">{{ __('datacenters.add') }}</h2>
<x-ui.input name="code" wire:model="code" :label="__('datacenters.code')" :hint="__('datacenters.code_hint')" /> <x-ui.input name="code" wire:model="code" :label="__('datacenters.code')" :hint="__('datacenters.code_hint')" />
<x-ui.input name="name" wire:model="name" :label="__('datacenters.name')" placeholder="Falkenstein" /> <x-ui.input name="name" wire:model="name" :label="__('datacenters.name')" placeholder="Falkenstein" />
<x-ui.input name="facility" wire:model="facility" :label="__('datacenters.facility')"
:hint="__('datacenters.facility_hint')" placeholder="fsn-dc-15" />
<div> <div>
<label class="text-sm font-medium text-body" for="dc-add-location">{{ __('datacenters.location') }}</label> <label class="text-sm font-medium text-body" for="dc-add-location">{{ __('datacenters.location') }}</label>
<select id="dc-add-location" wire:model="location" class="mt-1.5 w-full rounded-md border border-line-strong bg-surface px-3 py-2 text-sm text-body"> <select id="dc-add-location" wire:model="location" class="mt-1.5 w-full rounded-md border border-line-strong bg-surface px-3 py-2 text-sm text-body">

View File

@ -5,11 +5,37 @@
</div> </div>
<div class="mt-4 space-y-4"> <div class="mt-4 space-y-4">
{{-- Editable only while nothing references it. The code is not merely a
foreign key: each host's DNS name was minted from it and exists at
the provider, and the machine's own hostname was set from it while
it was built. Renaming a code with hosts in it produces a datacenter
called hel full of machines called fsn-01 so once anything depends
on it the field is a fact, and the reason is named rather than left
for the operator to guess at. --}}
<div>
<label class="text-sm font-medium text-body" for="dc-code">{{ __('datacenters.code') }}</label>
@if ($codeFree)
<input id="dc-code" type="text" wire:model="code" class="mt-1.5 w-full rounded-md border border-line-strong bg-surface px-3 py-2 font-mono text-sm text-ink" />
<p class="mt-1 text-xs text-muted">{{ __('datacenters.code_hint') }}</p>
@else
<input id="dc-code" type="text" value="{{ $code }}" disabled
class="mt-1.5 w-full cursor-not-allowed rounded-md border border-line bg-surface-2 px-3 py-2 font-mono text-sm text-muted" />
<p class="mt-1 text-xs text-muted">{{ __('datacenters.code_locked', ['hosts' => $hostCount, 'orders' => $orderCount]) }}</p>
@endif
@error('code')<p class="mt-1 text-xs text-danger">{{ $message }}</p>@enderror
</div>
<div> <div>
<label class="text-sm font-medium text-body" for="dc-name">{{ __('datacenters.name') }}</label> <label class="text-sm font-medium text-body" for="dc-name">{{ __('datacenters.name') }}</label>
<input id="dc-name" type="text" wire:model="name" class="mt-1.5 w-full rounded-md border border-line-strong bg-surface px-3 py-2 text-sm text-ink" /> <input id="dc-name" type="text" wire:model="name" class="mt-1.5 w-full rounded-md border border-line-strong bg-surface px-3 py-2 text-sm text-ink" />
@error('name')<p class="mt-1 text-xs text-danger">{{ $message }}</p>@enderror @error('name')<p class="mt-1 text-xs text-danger">{{ $message }}</p>@enderror
</div> </div>
<div>
<label class="text-sm font-medium text-body" for="dc-facility">{{ __('datacenters.facility') }}</label>
<input id="dc-facility" type="text" wire:model="facility" placeholder="fsn-dc-15"
class="mt-1.5 w-full rounded-md border border-line-strong bg-surface px-3 py-2 text-sm text-ink" />
<p class="mt-1 text-xs text-muted">{{ __('datacenters.facility_hint') }}</p>
@error('facility')<p class="mt-1 text-xs text-danger">{{ $message }}</p>@enderror
</div>
<div> <div>
<label class="text-sm font-medium text-body" for="dc-location">{{ __('datacenters.location') }}</label> <label class="text-sm font-medium text-body" for="dc-location">{{ __('datacenters.location') }}</label>
<select id="dc-location" wire:model="location" class="mt-1.5 w-full rounded-md border border-line-strong bg-surface px-3 py-2 text-sm text-body"> <select id="dc-location" wire:model="location" class="mt-1.5 w-full rounded-md border border-line-strong bg-surface px-3 py-2 text-sm text-body">
@ -17,8 +43,13 @@
@if ($originalLocation !== '' && ! array_key_exists($originalLocation, $countries)) @if ($originalLocation !== '' && ! array_key_exists($originalLocation, $countries))
<option value="{{ $originalLocation }}">{{ $originalLocation }}</option> <option value="{{ $originalLocation }}">{{ $originalLocation }}</option>
@endif @endif
@foreach ($countries as $code => $name) {{-- $cc/$cname, not $code/$name: those are the component's own
<option value="{{ $code }}">{{ $name }} ({{ $code }})</option> properties, and a loop variable of the same name silently
replaces them for the rest of the file. Harmless while
nothing below the loop read them which stopped being true
the moment the code became an editable field above. --}}
@foreach ($countries as $cc => $cname)
<option value="{{ $cc }}">{{ $cname }} ({{ $cc }})</option>
@endforeach @endforeach
</select> </select>
@error('location')<p class="mt-1 text-xs text-danger">{{ $message }}</p>@enderror @error('location')<p class="mt-1 text-xs text-danger">{{ $message }}</p>@enderror

View File

@ -14,8 +14,12 @@
<label for="datacenter" class="block text-sm font-medium text-body">{{ __('hosts.field.datacenter') }}</label> <label for="datacenter" class="block text-sm font-medium text-body">{{ __('hosts.field.datacenter') }}</label>
<select id="datacenter" wire:model="datacenter" <select id="datacenter" wire:model="datacenter"
class="block w-full rounded border border-line bg-surface px-3 py-2 text-sm text-ink transition"> class="block w-full rounded border border-line bg-surface px-3 py-2 text-sm text-ink transition">
{{-- The building too, where it is known: two entries both
reading "Falkenstein (fsn)" are not a choice anybody can
make, and placing a second machine for redundancy is
exactly when it matters which hall it lands in. --}}
@foreach ($datacenters as $dc) @foreach ($datacenters as $dc)
<option value="{{ $dc->code }}">{{ $dc->name }} ({{ $dc->code }})</option> <option value="{{ $dc->code }}">{{ $dc->name }} ({{ $dc->code }})@if ($dc->facility) · {{ $dc->facility }}@endif</option>
@endforeach @endforeach
</select> </select>
@error('datacenter') <p class="text-xs text-danger">{{ $message }}</p> @enderror @error('datacenter') <p class="text-xs text-danger">{{ $message }}</p> @enderror

View File

@ -52,6 +52,133 @@ it('edits a datacenter name and country via the modal', function () {
expect($dc->name)->toBe('Nürnberg')->and($dc->location)->toBe('DE')->and($dc->code)->toBe('nbg'); expect($dc->name)->toBe('Nürnberg')->and($dc->location)->toBe('DE')->and($dc->code)->toBe('nbg');
}); });
/**
* Renaming a code, and why it stops being allowed.
*
* The code was immutable, which made a typo permanent. It cannot be freely
* mutable either: hosts.datacenter references it, every past order stores it as
* plain text, and the part no cascade reaches each host's DNS name was
* minted from it and registered at the provider, while the machine's own
* hostname was set from it as it was built. Rename a code with hosts in it and
* you get a datacenter called hel full of machines called fsn-01.
*
* Free while nothing references it, which is the case that matters: a typo
* noticed shortly after creating one.
*/
it('renames a code while nothing depends on it', function () {
$dc = Datacenter::factory()->create(['code' => 'nbg', 'name' => 'Nürnberg']);
Livewire::actingAs(admin(), 'operator')
->test(\App\Livewire\Admin\EditDatacenter::class, ['uuid' => $dc->uuid])
->set('code', 'NBG2')
->call('save')
->assertHasNoErrors();
// Lowercased on the way in, like the create form does it.
expect($dc->refresh()->code)->toBe('nbg2');
});
it('leaves the code alone once a host is placed there, whatever the browser sends back', function () {
// fsn and hel exist from the migration that created the table.
$dc = Datacenter::query()->firstOrCreate(['code' => 'fsn'], ['name' => 'Falkenstein']);
Host::factory()->create(['datacenter' => 'fsn']);
// set() on a locked field is exactly the forged request this guards
// against: the input is not rendered, so nothing honest can send it.
Livewire::actingAs(admin(), 'operator')
->test(\App\Livewire\Admin\EditDatacenter::class, ['uuid' => $dc->uuid])
->set('code', 'hel')
->set('name', 'Falkenstein Süd')
->call('save')
->assertHasNoErrors();
expect($dc->refresh()->code)->toBe('fsn')
->and($dc->name)->toBe('Falkenstein Süd'); // the rest of the form still saves
});
it('leaves the code alone once an order carries it, even with no hosts left', function () {
// Orders are the reference no foreign key protects: a plain string column,
// and the record of what a customer actually bought.
$dc = Datacenter::query()->firstOrCreate(['code' => 'fsn'], ['name' => 'Falkenstein']);
\App\Models\Order::factory()->create(['datacenter' => 'fsn']);
Livewire::actingAs(admin(), 'operator')
->test(\App\Livewire\Admin\EditDatacenter::class, ['uuid' => $dc->uuid])
->set('code', 'hel')
->call('save')
->assertHasNoErrors();
expect($dc->refresh()->code)->toBe('fsn');
});
it('holds a renamed code to the same rules as a new one', function () {
// hel comes from the migration that created the table.
$dc = Datacenter::factory()->create(['code' => 'nbg']);
$component = Livewire::actingAs(admin(), 'operator')
->test(\App\Livewire\Admin\EditDatacenter::class, ['uuid' => $dc->uuid]);
// Not a DNS label — the code becomes one (fsn-01.node.clupilot.com).
$component->set('code', 'nbg_1')->call('save')->assertHasErrors(['code']);
// Already taken.
$component->set('code', 'hel')->call('save')->assertHasErrors(['code']);
expect($dc->refresh()->code)->toBe('nbg');
});
it('lets a datacenter keep its own code when only the name is edited', function () {
// The unique rule has to ignore this row, or saving anything at all would
// fail on the code the record already holds.
$dc = Datacenter::factory()->create(['code' => 'nbg', 'name' => 'Old']);
Livewire::actingAs(admin(), 'operator')
->test(\App\Livewire\Admin\EditDatacenter::class, ['uuid' => $dc->uuid])
->set('name', 'Nürnberg')
->call('save')
->assertHasNoErrors();
expect($dc->refresh()->name)->toBe('Nürnberg')->and($dc->code)->toBe('nbg');
});
/**
* Which building, not which city.
*
* A provider runs several datacenters within one location, on separate power
* and separate uplinks. "Falkenstein (fsn)" twice over is not a choice anybody
* can make and placing a second machine for redundancy is exactly when it
* matters which hall it lands in.
*/
it('stores the specific datacenter and shows it where hosts are placed', function () {
Livewire::actingAs(admin(), 'operator')
->test(Datacenters::class)
->set('code', 'nbg')
->set('name', 'Nürnberg')
->set('facility', 'nbg-dc-15')
->call('save')
->assertHasNoErrors();
expect(Datacenter::query()->where('code', 'nbg')->value('facility'))->toBe('nbg-dc-15');
Livewire::actingAs(admin(), 'operator')
->test(HostCreate::class)
->assertSee('nbg-dc-15');
});
it('keeps the specific datacenter optional, and empty rather than blank', function () {
// '' in the column would read as "there is one and it has no name" to
// every @if that decides whether to print it.
$dc = Datacenter::factory()->create(['code' => 'nbg', 'facility' => 'nbg-dc-3']);
Livewire::actingAs(admin(), 'operator')
->test(\App\Livewire\Admin\EditDatacenter::class, ['uuid' => $dc->uuid])
->set('facility', '')
->call('save')
->assertHasNoErrors();
expect($dc->refresh()->facility)->toBeNull();
});
it('rejects an invalid country code on edit', function () { it('rejects an invalid country code on edit', function () {
$dc = Datacenter::factory()->create(['code' => 'nbg', 'location' => 'DE']); $dc = Datacenter::factory()->create(['code' => 'nbg', 'location' => 'DE']);