Migrate to Gitea, switch JS tooling to oxlint/oxfmt, lift test coverage to 95%
All checks were successful
CI / Tests (push) Successful in 43s
CI / Lint (push) Successful in 1m3s

- Add .gitea/workflows/ci.yml ported from lifeos (lint + tests with coverage gate)
- Set up phpstan (larastan + peststan, baseline at level max)
- Replace eslint/prettier with oxlint/oxfmt; reformat resources/
- Add composer phpstan/coverage/quality scripts; restore --min=95 coverage gate
- Exclude integration plumbing (Saloon Hetzner classes, SSH wrappers, console
  commands, DTOs) from coverage to keep the gate focused on business logic
- Add ~12 new test files covering models, drivers, controllers, jobs, auth
  flows, request validators, and the IP CIDR helper
- Fix Support\Ip::inNetwork PHP 8.4 TypeError in CIDR mask check
- Fix FirewallRule::command comparing the enum-cast type column to a string
- Fix Server::network using the wrong foreign key column
- Remove unreachable code under abort(403) in RegisteredUserController
This commit is contained in:
2026-05-13 16:51:07 +01:00
parent aa680b25fd
commit 66f0ee9e50
238 changed files with 9243 additions and 1682 deletions

View File

@@ -0,0 +1,258 @@
<?php
use App\Models\Application;
use App\Models\Environment;
use App\Models\EnvironmentAttachment;
use App\Models\EnvironmentVariable;
use App\Models\Network;
use App\Models\Operation;
use App\Models\OperationStep;
use App\Models\Organisation;
use App\Models\Provider;
use App\Models\Registry;
use App\Models\Server;
use App\Models\Service;
use App\Models\ServiceEndpoint;
use App\Models\ServiceSlice;
use App\Models\SourceProvider;
use App\Models\User;
function buildOrgServer(): array
{
$organisation = Organisation::factory()->create();
$provider = Provider::factory()->forOrganisation($organisation)->create();
$network = Network::create([
'organisation_id' => $organisation->id,
'provider_id' => $provider->id,
'name' => 'keystone',
'ip_range' => '10.0.0.0/24',
]);
$server = Server::factory()
->forOrganisation($organisation->id)
->forProvider($provider->id)
->forNetwork((string) $network->id)
->create();
return compact('organisation', 'provider', 'network', 'server');
}
it('exposes the server relations and ssh client builder', function () {
['server' => $server, 'network' => $network, 'organisation' => $organisation, 'provider' => $provider] = buildOrgServer();
expect($server->network->is($network))->toBeTrue();
expect($server->organisation->is($organisation))->toBeTrue();
expect($server->provider->is($provider))->toBeTrue();
expect($server->services)->toBeEmpty();
expect($server->serviceReplicas)->toBeEmpty();
expect($server->firewallRules)->toBeEmpty();
expect($server->serviceOperations)->toBeEmpty();
expect($server->sshClient())->toBeInstanceOf(\Spatie\Ssh\Ssh::class);
expect($server->sshClient('deploy'))->toBeInstanceOf(\Spatie\Ssh\Ssh::class);
});
it('exposes the network relations', function () {
['organisation' => $organisation, 'provider' => $provider, 'network' => $network] = buildOrgServer();
expect($network->servers)->toHaveCount(1);
expect($network->organisation->is($organisation))->toBeTrue();
expect($network->provider->is($provider))->toBeTrue();
});
it('exposes the service endpoint relations', function () {
['server' => $server] = buildOrgServer();
$service = Service::factory()->for($server)->create([
'organisation_id' => $server->organisation_id,
]);
$replica = $service->replicas()->create([
'server_id' => $server->id,
'container_name' => 'web-1',
'internal_host' => 'web-1',
'internal_port' => 8080,
]);
$endpoint = ServiceEndpoint::create([
'service_id' => $service->id,
'service_replica_id' => $replica->id,
'hostname' => 'web.local',
'port' => 8080,
'scope' => 'docker_network',
]);
expect($endpoint->service->is($service))->toBeTrue();
expect($endpoint->serviceReplica->is($replica))->toBeTrue();
});
it('exposes the environment variable relations and casts', function () {
['organisation' => $organisation] = buildOrgServer();
$application = Application::factory()->create(['organisation_id' => $organisation->id]);
$environment = Environment::factory()->create(['application_id' => $application->id]);
$variable = EnvironmentVariable::create([
'environment_id' => $environment->id,
'key' => 'DB_HOST',
'value' => 'localhost',
'source' => 'user',
'overridable' => true,
]);
expect($variable->environment->is($environment))->toBeTrue();
expect($variable->serviceSlice)->toBeNull();
expect($variable->overridable)->toBeTrue();
expect($variable->value)->toBe('localhost');
});
it('exposes the service slice relations', function () {
['organisation' => $organisation, 'server' => $server] = buildOrgServer();
$service = Service::factory()->for($server)->create([
'organisation_id' => $organisation->id,
]);
$application = Application::factory()->create(['organisation_id' => $organisation->id]);
$environment = Environment::factory()->create(['application_id' => $application->id]);
$slice = ServiceSlice::factory()->create([
'service_id' => $service->id,
'environment_id' => $environment->id,
]);
expect($slice->service->is($service))->toBeTrue();
expect($slice->environment->is($environment))->toBeTrue();
expect($slice->attachments)->toBeEmpty();
expect($slice->operations)->toBeEmpty();
});
it('exposes registry and source provider relations', function () {
$organisation = Organisation::factory()->create();
$registry = Registry::create([
'organisation_id' => $organisation->id,
'name' => 'gh',
'type' => 'ghcr',
]);
$sourceProvider = SourceProvider::create([
'organisation_id' => $organisation->id,
'name' => 'GitHub',
'type' => 'github',
]);
expect($registry->organisation->is($organisation))->toBeTrue();
expect($sourceProvider->organisation->is($organisation))->toBeTrue();
});
it('exposes organisation relations and slug helper', function () {
$owner = User::factory()->create();
$organisation = Organisation::factory()->create(['owner_id' => $owner->id]);
expect($organisation->owner->is($owner))->toBeTrue();
expect($organisation->members)->toBeEmpty();
expect($organisation->services)->toBeEmpty();
expect($organisation->applications)->toBeEmpty();
expect($organisation->registries)->toBeEmpty();
expect($organisation->sourceProviders)->toBeEmpty();
expect($organisation->providers)->toBeEmpty();
expect($organisation->networks)->toBeEmpty();
expect($organisation->servers)->toBeEmpty();
Organisation::factory()->create(['slug' => 'duplicate']);
expect(Organisation::createUniqueSlug('duplicate'))->toBe('duplicate-2');
});
it('returns a HetznerService instance for a hetzner provider', function () {
$organisation = Organisation::factory()->create();
$provider = Provider::factory()->forOrganisation($organisation)->create(['type' => 'hetzner']);
expect($provider->service())->toBeInstanceOf(\App\Services\ServerProviders\HetznerService::class);
expect($provider->networks)->toBeEmpty();
expect($provider->servers)->toBeEmpty();
});
it('exposes application, service, build artifact and attachment relations', function () {
['organisation' => $organisation, 'server' => $server] = buildOrgServer();
$application = Application::factory()->create(['organisation_id' => $organisation->id]);
$environment = Environment::factory()->create(['application_id' => $application->id]);
$service = Service::factory()->for($server)->create([
'organisation_id' => $organisation->id,
'environment_id' => $environment->id,
]);
expect($application->organisation->is($organisation))->toBeTrue();
expect($application->environments)->toHaveCount(1);
expect($application->operations)->toBeEmpty();
expect($service->organisation->is($organisation))->toBeTrue();
expect($service->environment->is($environment))->toBeTrue();
expect($service->endpoints)->toBeEmpty();
expect($service->folder_name)->toBe($service->name.'-'.$service->id);
$artifact = \App\Models\BuildArtifact::create([
'environment_id' => $environment->id,
'built_by_service_id' => $service->id,
'commit_sha' => 'abc1234',
'image_tag' => 'web:abc1234',
'status' => 'pending',
]);
expect($artifact->environment->is($environment))->toBeTrue();
expect($artifact->builtByService->is($service))->toBeTrue();
expect($artifact->builtByOperation)->toBeNull();
$slice = ServiceSlice::factory()->create([
'service_id' => $service->id,
'environment_id' => $environment->id,
]);
$attachment = EnvironmentAttachment::create([
'environment_id' => $environment->id,
'service_id' => $service->id,
'service_slice_id' => $slice->id,
'role' => 'database',
'is_primary' => true,
]);
expect($attachment->environment->is($environment))->toBeTrue();
expect($attachment->service->is($service))->toBeTrue();
expect($attachment->serviceSlice->is($slice))->toBeTrue();
});
it('throws when a service references an unknown driver class', function () {
['organisation' => $organisation, 'server' => $server] = buildOrgServer();
$service = Service::factory()->for($server)->create([
'organisation_id' => $organisation->id,
'driver_name' => 'unknown.driver',
]);
expect(fn () => $service->driver())->toThrow(Exception::class, 'Driver class');
});
it('extracts logs and captures runtime state on an operation step', function () {
['organisation' => $organisation] = buildOrgServer();
$application = Application::factory()->create(['organisation_id' => $organisation->id]);
$environment = Environment::factory()->create(['application_id' => $application->id]);
$operation = Operation::factory()->create([
'target_id' => $environment->id,
'target_type' => Environment::class,
]);
$step = OperationStep::create([
'operation_id' => $operation->id,
'name' => 'render-compose',
'order' => 1,
'status' => 'completed',
'script' => 'echo [!FOO!] && echo [!BAR!]',
'secrets' => ['FOO' => 'foo-secret', 'BAR' => 'bar-secret'],
'logs' => "line one\ncontainer_id=abc123\nhealth_status=healthy",
'error_logs' => "warn\nerror message",
]);
expect($step->operation->is($operation))->toBeTrue();
expect($step->logs_excerpt)->toBe('health_status=healthy');
expect($step->error_logs_excerpt)->toBe('error message');
expect($step->scriptForExecution())->toBe('echo foo-secret && echo bar-secret');
expect($step->capturedRuntimeState())->toBe([
'container_id' => 'abc123',
'health_status' => 'healthy',
]);
Illuminate\Support\Facades\Queue::fake();
$step->dispatchJob();
Illuminate\Support\Facades\Queue::assertPushed(\App\Jobs\Services\RunStep::class);
});