392 lines
16 KiB
PHP
392 lines
16 KiB
PHP
<?php
|
|
|
|
namespace Tests\Feature;
|
|
|
|
use App\Livewire\Versions\Index;
|
|
use App\Models\AuditEvent;
|
|
use App\Models\Setting;
|
|
use App\Models\User;
|
|
use App\Services\DeploymentService;
|
|
use Illuminate\Foundation\Testing\RefreshDatabase;
|
|
use Illuminate\Support\Facades\Cache;
|
|
use Illuminate\Support\Facades\Http;
|
|
use Illuminate\Support\Facades\RateLimiter;
|
|
use Livewire\Livewire;
|
|
use PHPUnit\Framework\Attributes\DataProvider;
|
|
use Tests\TestCase;
|
|
|
|
/**
|
|
* The "check for updates" action. The production image ships no .git, so the latest release
|
|
* must come from the public Git host's tag API — not a local read (which would always be empty
|
|
* in prod and wrongly report "no release tagged"). These tests drive that API with Http::fake.
|
|
*/
|
|
class VersionUpdateCheckTest extends TestCase
|
|
{
|
|
use RefreshDatabase;
|
|
|
|
// Neutral self-hosted host so the suite is hermetic (independent of the ambient .env) and the
|
|
// published tests carry no real repository URL.
|
|
private const REPO = 'https://selfhosted.test/o/clusev';
|
|
|
|
private const TAGS_URL = 'selfhosted.test/api/v1/repos/o/clusev/tags*';
|
|
|
|
protected function setUp(): void
|
|
{
|
|
parent::setUp();
|
|
Cache::flush(); // never inherit a cached latest-release between tests
|
|
config()->set('clusev.repository', self::REPO);
|
|
app(DeploymentService::class)->clearUpdateRequest(); // never inherit a stray sentinel
|
|
$this->actingAs(User::factory()->create(['must_change_password' => false]));
|
|
}
|
|
|
|
protected function tearDown(): void
|
|
{
|
|
app(DeploymentService::class)->clearUpdateRequest(); // leave the storage dir clean
|
|
parent::tearDown();
|
|
}
|
|
|
|
/** The cache key ReleaseChecker uses for the configured repo (host-discriminated). */
|
|
private function tagKey(string $channel = 'stable'): string
|
|
{
|
|
return 'clusev:latest-release:'.substr(md5(self::REPO), 0, 8).':'.$channel;
|
|
}
|
|
|
|
public function test_reports_update_available_from_the_remote_tag_api(): void
|
|
{
|
|
config()->set('clusev.version', '0.9.4');
|
|
Http::fake([self::TAGS_URL => Http::response([
|
|
['name' => 'v0.9.6'], ['name' => 'v0.9.5'], ['name' => 'v0.9.4'],
|
|
])]);
|
|
|
|
Livewire::test(Index::class)
|
|
->call('checkUpdates')
|
|
->assertSet('updateState', 'update')
|
|
->assertSee('0.9.6');
|
|
}
|
|
|
|
public function test_shows_the_available_versions_changelog_before_updating(): void
|
|
{
|
|
config()->set('clusev.version', '0.9.4');
|
|
Http::fake([
|
|
self::TAGS_URL => Http::response([['name' => 'v0.9.6'], ['name' => 'v0.9.5'], ['name' => 'v0.9.4']]),
|
|
'selfhosted.test/api/v1/repos/o/clusev/raw/CHANGELOG.md*' => Http::response(
|
|
"# Changelog\n\n## [0.9.6] - 2026-06-22\n\n### Hinzugefügt\n- Brandneues Feature XYZ.\n\n".
|
|
"## [0.9.5] - 2026-06-22\n\n### Behoben\n- Fehler ABC behoben.\n\n".
|
|
"## [0.9.4] - 2026-06-21\n\n### Geändert\n- Schon installiert.\n"
|
|
),
|
|
]);
|
|
|
|
Livewire::test(Index::class)
|
|
->call('checkUpdates')
|
|
->assertSet('updateState', 'update')
|
|
->assertViewHas('availableUpdates', fn ($u) => count($u) === 2) // 0.9.6 + 0.9.5 only
|
|
->assertSee('Brandneues Feature XYZ')
|
|
->assertSee('Fehler ABC behoben')
|
|
->assertDontSee('Schon installiert'); // 0.9.4 == installed → excluded
|
|
|
|
// Tags are published as "vX.Y.Z" — the CHANGELOG.md must be fetched at the v-prefixed ref,
|
|
// not the bare version, or it 404s on a real host and the preview silently stays empty.
|
|
Http::assertSent(fn ($req) => ! str_contains($req->url(), 'raw/CHANGELOG.md')
|
|
|| str_contains($req->url(), 'ref=v0.9.6'));
|
|
}
|
|
|
|
public function test_no_whats_new_preview_when_already_current(): void
|
|
{
|
|
config()->set('clusev.version', '0.9.9');
|
|
Http::fake([self::TAGS_URL => Http::response([['name' => 'v0.9.6']])]);
|
|
|
|
Livewire::test(Index::class)
|
|
->call('checkUpdates')
|
|
->assertViewHas('availableUpdates', []);
|
|
}
|
|
|
|
public function test_reports_current_when_installed_is_at_or_above_the_latest_tag(): void
|
|
{
|
|
config()->set('clusev.version', '0.9.9');
|
|
Http::fake([self::TAGS_URL => Http::response([['name' => 'v0.9.6']])]);
|
|
|
|
Livewire::test(Index::class)
|
|
->call('checkUpdates')
|
|
->assertSet('updateState', 'current');
|
|
}
|
|
|
|
public function test_stable_channel_ignores_prereleases_but_beta_sees_them(): void
|
|
{
|
|
config()->set('clusev.version', '0.9.6');
|
|
Http::fake([self::TAGS_URL => Http::response([
|
|
['name' => 'v1.0.0-beta1'], ['name' => 'v0.9.6'],
|
|
])]);
|
|
|
|
// stable: 1.0.0-beta1 is not offered, 0.9.6 == installed → current
|
|
Setting::put('release_channel', 'stable');
|
|
Livewire::test(Index::class)->call('checkUpdates')->assertSet('updateState', 'current');
|
|
|
|
// beta: the prerelease is newer → update available
|
|
Cache::flush();
|
|
Setting::put('release_channel', 'beta');
|
|
Livewire::test(Index::class)
|
|
->call('checkUpdates')
|
|
->assertSet('updateState', 'update')
|
|
->assertSee('1.0.0-beta1');
|
|
}
|
|
|
|
public function test_degrades_gracefully_when_the_remote_api_is_unreachable(): void
|
|
{
|
|
// Installed version above any real local .git tag, so the local fallback can't claim an
|
|
// update — this asserts a failed remote lookup never crashes the action.
|
|
config()->set('clusev.version', '99.0.0');
|
|
Http::fake([self::TAGS_URL => Http::response('upstream down', 500)]);
|
|
|
|
Livewire::test(Index::class)
|
|
->call('checkUpdates')
|
|
->assertSet('updateState', 'current')
|
|
->assertSet('lastChecked', now()->format('H:i'));
|
|
}
|
|
|
|
public function test_request_update_writes_the_sentinel_and_audits_when_available(): void
|
|
{
|
|
config()->set('clusev.version', '0.9.4');
|
|
Http::fake([self::TAGS_URL => Http::response([['name' => 'v0.9.6']])]);
|
|
|
|
Livewire::test(Index::class)
|
|
->call('requestUpdate')
|
|
->assertSet('updateRequested', true);
|
|
|
|
$this->assertTrue(
|
|
app(DeploymentService::class)->updateRequested(),
|
|
'the update sentinel a host watcher reacts to must exist',
|
|
);
|
|
$this->assertTrue(AuditEvent::where('action', 'deploy.update_request')->exists());
|
|
}
|
|
|
|
public function test_request_update_refuses_when_already_current(): void
|
|
{
|
|
config()->set('clusev.version', '0.9.9');
|
|
Http::fake([self::TAGS_URL => Http::response([['name' => 'v0.9.6']])]);
|
|
|
|
Livewire::test(Index::class)
|
|
->call('requestUpdate')
|
|
->assertSet('updateRequested', false)
|
|
->assertSet('updateState', 'current');
|
|
|
|
$this->assertFalse(
|
|
app(DeploymentService::class)->updateRequested(),
|
|
'no sentinel when there is nothing newer to apply',
|
|
);
|
|
}
|
|
|
|
public function test_passive_latest_release_never_shows_older_than_installed(): void
|
|
{
|
|
// A stale cache from before an update must not render a "latest" below the installed
|
|
// version (which would look like the panel is behind a release it has already passed).
|
|
config()->set('clusev.version', '0.9.10');
|
|
Cache::put($this->tagKey(), '0.9.9', now()->addMinutes(30));
|
|
|
|
Livewire::test(Index::class)->assertViewHas('latestTag', null);
|
|
}
|
|
|
|
public function test_auto_check_on_load_surfaces_an_update_without_a_manual_click(): void
|
|
{
|
|
config()->set('clusev.version', '0.9.10');
|
|
Http::fake([self::TAGS_URL => Http::response([['name' => 'v0.9.13']])]);
|
|
|
|
Livewire::test(Index::class)
|
|
->call('autoCheck')
|
|
->assertSet('updateState', 'update')
|
|
->assertSee('0.9.13');
|
|
}
|
|
|
|
public function test_auto_check_does_not_run_while_an_update_is_in_progress(): void
|
|
{
|
|
Http::fake([self::TAGS_URL => Http::response([['name' => 'v0.9.13']])]);
|
|
|
|
Livewire::test(Index::class)
|
|
->set('updateRequested', true)
|
|
->call('autoCheck');
|
|
|
|
Http::assertNothingSent(); // guarded — never clobbers the running state
|
|
}
|
|
|
|
public function test_update_available_is_shown_on_load_without_clicking_check(): void
|
|
{
|
|
// The cached check (badge already knew) surfaces the update + button on mount, no re-click.
|
|
config()->set('clusev.version', '0.9.10');
|
|
Cache::put($this->tagKey(), '0.9.11', now()->addMinutes(30));
|
|
|
|
Livewire::test(Index::class)
|
|
->assertSet('updateState', 'update')
|
|
->assertSee('0.9.11');
|
|
}
|
|
|
|
public function test_an_in_progress_marker_resumes_the_running_state_on_load(): void
|
|
{
|
|
// Redis marker (not the consumed sentinel) is what survives a reload mid-update.
|
|
config()->set('clusev.version', '0.9.11');
|
|
Cache::put('clusev:update-in-progress', ['base' => '0.9.11'], now()->addMinutes(15));
|
|
|
|
Livewire::test(Index::class)->assertSet('updateRequested', true);
|
|
}
|
|
|
|
public function test_a_finished_update_is_announced_and_cleared_on_load(): void
|
|
{
|
|
// Reloaded after the rebuild: the running version is now past the marked base → done.
|
|
config()->set('clusev.version', '0.9.12');
|
|
Cache::put('clusev:update-in-progress', ['base' => '0.9.11'], now()->addMinutes(15));
|
|
|
|
Livewire::test(Index::class)
|
|
->assertSet('updateRequested', false)
|
|
->assertSet('updateState', 'current')
|
|
->assertSee('0.9.12');
|
|
|
|
$this->assertNull(Cache::get('clusev:update-in-progress'), 'marker cleared once the update landed');
|
|
}
|
|
|
|
public function test_poll_update_reports_completion_when_the_version_moves_past_the_base(): void
|
|
{
|
|
config()->set('clusev.version', '0.9.10');
|
|
|
|
$c = Livewire::test(Index::class)
|
|
->set('updateRequested', true)
|
|
->set('updateBaseVersion', '0.9.10');
|
|
|
|
// Still the old version mid-rebuild → keeps waiting.
|
|
$c->call('pollUpdate')->assertSet('updateRequested', true);
|
|
|
|
// Rebuilt container now reports a newer version → done + back to "current".
|
|
config()->set('clusev.version', '0.9.11');
|
|
$c->call('pollUpdate')
|
|
->assertSet('updateRequested', false)
|
|
->assertSet('updateState', 'current')
|
|
->assertSee('0.9.11');
|
|
}
|
|
|
|
public function test_build_info_prefers_the_baked_config_over_a_git_read(): void
|
|
{
|
|
// Production bakes sha/branch into config via .env (no .git in the image).
|
|
config()->set('clusev.build.sha', 'abc1234');
|
|
config()->set('clusev.build.branch', 'feat/v1-foundation');
|
|
|
|
Livewire::test(Index::class)
|
|
->assertViewHas('build', ['sha' => 'abc1234', 'branch' => 'feat/v1-foundation'])
|
|
->assertSee('abc1234')
|
|
->assertSee('feat/v1-foundation');
|
|
}
|
|
|
|
public function test_update_surfaces_an_error_when_the_sentinel_cannot_be_written(): void
|
|
{
|
|
config()->set('clusev.version', '0.9.10');
|
|
Http::fake([self::TAGS_URL => Http::response([['name' => 'v0.9.14']])]);
|
|
$mock = \Mockery::mock(DeploymentService::class)->makePartial();
|
|
$mock->shouldReceive('requestUpdate')->andReturn(false); // unwritable sentinel dir
|
|
$this->app->instance(DeploymentService::class, $mock);
|
|
|
|
Livewire::test(Index::class)
|
|
->call('requestUpdate')
|
|
->assertSet('updateRequested', false);
|
|
|
|
$this->assertNull(Cache::get('clusev:update-in-progress'), 'no in-progress marker when the sentinel write failed');
|
|
}
|
|
|
|
public function test_request_update_is_throttled_per_user(): void
|
|
{
|
|
config()->set('clusev.version', '0.9.4');
|
|
Http::fake([self::TAGS_URL => Http::response([['name' => 'v0.9.6']])]);
|
|
$key = 'update-request:'.auth()->id();
|
|
for ($i = 0; $i < 3; $i++) {
|
|
RateLimiter::hit($key, 600);
|
|
}
|
|
|
|
Livewire::test(Index::class)
|
|
->call('requestUpdate')
|
|
->assertSet('updateRequested', false);
|
|
|
|
$this->assertFalse(
|
|
app(DeploymentService::class)->updateRequested(),
|
|
'the per-user throttle must block the sentinel write',
|
|
);
|
|
}
|
|
|
|
// ── Project links: dev lists source + public; everyone else only public ──────
|
|
|
|
/** @param array<int,array{url:string}> $repos */
|
|
private static function urls(array $repos): array
|
|
{
|
|
return array_column($repos, 'url');
|
|
}
|
|
|
|
public function test_dev_box_lists_its_source_and_the_public_repo(): void
|
|
{
|
|
config()->set('clusev.release_controls', true); // the dev / release-control box
|
|
config()->set('clusev.public_slug', 'clusev/clusev');
|
|
Http::fake();
|
|
|
|
// setUp() set clusev.repository to the (self-hosted) source — the dev box lists it FIRST,
|
|
// then the public repo. Both visible.
|
|
Livewire::test(Index::class)->assertViewHas('repositories', fn ($repos) => self::urls($repos) === [
|
|
self::REPO, 'https://github.com/clusev/clusev',
|
|
]);
|
|
}
|
|
|
|
public function test_dev_box_drops_a_garbage_source_url(): void
|
|
{
|
|
config()->set('clusev.release_controls', true);
|
|
config()->set('clusev.repository', 'not-a-url'); // misconfigured source (no host)
|
|
config()->set('clusev.public_slug', 'clusev/clusev');
|
|
Http::fake();
|
|
|
|
// A garbage source is dropped (would render an empty link) — the public repo still shows.
|
|
Livewire::test(Index::class)->assertViewHas('repositories', fn ($repos) => self::urls($repos) === [
|
|
'https://github.com/clusev/clusev',
|
|
]);
|
|
}
|
|
|
|
public function test_non_dev_install_lists_only_the_public_repo(): void
|
|
{
|
|
config()->set('clusev.release_controls', false); // staging / stable
|
|
config()->set('clusev.public_slug', 'clusev/clusev');
|
|
Http::fake();
|
|
|
|
// Even though clusev.repository points at the private self-hosted source (setUp), the card lists
|
|
// ONLY the public repo — a non-dev server never exposes the private source.
|
|
Livewire::test(Index::class)->assertViewHas('repositories', fn ($repos) => self::urls($repos) === [
|
|
'https://github.com/clusev/clusev',
|
|
]);
|
|
}
|
|
|
|
public function test_non_dev_public_link_follows_the_public_slug(): void
|
|
{
|
|
config()->set('clusev.release_controls', false);
|
|
config()->set('clusev.public_slug', 'acme/pub');
|
|
Http::fake();
|
|
|
|
Livewire::test(Index::class)->assertViewHas('repositories', fn ($repos) => self::urls($repos) === [
|
|
'https://github.com/acme/pub',
|
|
]);
|
|
}
|
|
|
|
/**
|
|
* A malformed public_slug must never produce a private/garbage link — it falls back to the
|
|
* canonical public repo. Covers empty, surrounding slashes, and a slug mistakenly set to a full
|
|
* (possibly private) URL.
|
|
*/
|
|
#[DataProvider('malformedSlugs')]
|
|
public function test_non_dev_public_link_falls_back_for_a_malformed_slug(string $slug, string $expected): void
|
|
{
|
|
config()->set('clusev.release_controls', false);
|
|
config()->set('clusev.public_slug', $slug);
|
|
Http::fake();
|
|
|
|
Livewire::test(Index::class)->assertViewHas('repositories', fn ($repos) => self::urls($repos) === [$expected]);
|
|
}
|
|
|
|
/** @return array<string,array{0:string,1:string}> */
|
|
public static function malformedSlugs(): array
|
|
{
|
|
return [
|
|
'empty' => ['', 'https://github.com/clusev/clusev'],
|
|
'surrounding slashes' => ['/acme/pub/', 'https://github.com/acme/pub'],
|
|
'full private url' => ['https://selfhosted.internal/o/repo', 'https://github.com/clusev/clusev'],
|
|
];
|
|
}
|
|
}
|