407 lines
16 KiB
PHP
407 lines
16 KiB
PHP
<?php
|
|
|
|
namespace Tests\Feature;
|
|
|
|
use App\Livewire\Versions\Index;
|
|
use App\Models\AuditEvent;
|
|
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 Tests\TestCase;
|
|
|
|
/**
|
|
* Verifies the graceful update-progress redirect flow:
|
|
* (a) requestUpdate() still writes the sentinel, sets the in-progress Redis flag,
|
|
* respects the throttle, AND now redirects to route('update.progress').
|
|
* (b) GET /update-progress returns 200, contains the heading, and is Livewire-free.
|
|
*/
|
|
class UpdateProgressTest extends TestCase
|
|
{
|
|
use RefreshDatabase;
|
|
|
|
private const REPO = 'https://gitea.test/o/clusev';
|
|
|
|
private const TAGS_URL = 'gitea.test/api/v1/repos/o/clusev/tags*';
|
|
|
|
protected function setUp(): void
|
|
{
|
|
parent::setUp();
|
|
Cache::flush();
|
|
config()->set('clusev.repository', self::REPO);
|
|
app(DeploymentService::class)->clearUpdateRequest();
|
|
$this->actingAs(User::factory()->create(['must_change_password' => false]));
|
|
}
|
|
|
|
protected function tearDown(): void
|
|
{
|
|
app(DeploymentService::class)->clearUpdateRequest();
|
|
parent::tearDown();
|
|
}
|
|
|
|
// ── (a) requestUpdate() behaviour ─────────────────────────────────────────
|
|
|
|
public function test_request_update_redirects_to_progress_page(): void
|
|
{
|
|
config()->set('clusev.version', '0.9.4');
|
|
Http::fake([self::TAGS_URL => Http::response([['name' => 'v0.9.6']])]);
|
|
|
|
// The redirect includes a ?return= query param; assertRedirectContains checks the path.
|
|
$response = Livewire::test(Index::class)->call('requestUpdate');
|
|
$redirectUrl = $response->effects['redirect'] ?? '';
|
|
$this->assertStringContainsString(
|
|
route('update.progress', absolute: false),
|
|
$redirectUrl,
|
|
'requestUpdate must redirect to the update-progress page',
|
|
);
|
|
$this->assertStringContainsString(
|
|
'from=0.9.4',
|
|
$redirectUrl,
|
|
'the redirect must carry the pre-update version so the page can detect a real change',
|
|
);
|
|
}
|
|
|
|
public function test_request_update_still_writes_sentinel_and_sets_redis_flag(): 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');
|
|
|
|
$this->assertTrue(
|
|
app(DeploymentService::class)->updateRequested(),
|
|
'the update sentinel a host watcher reacts to must still be written',
|
|
);
|
|
$marker = Cache::get('clusev:update-in-progress');
|
|
$this->assertIsArray($marker, 'the in-progress Redis flag must be set');
|
|
$this->assertSame('0.9.4', $marker['base'], 'the base version must be captured in the flag');
|
|
}
|
|
|
|
public function test_request_update_still_audits_the_action(): 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');
|
|
|
|
$this->assertTrue(AuditEvent::where('action', 'deploy.update_request')->exists());
|
|
}
|
|
|
|
public function test_request_update_still_respects_throttle(): 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);
|
|
}
|
|
|
|
// Throttled — must NOT redirect, must NOT write sentinel.
|
|
Livewire::test(Index::class)
|
|
->call('requestUpdate')
|
|
->assertSet('updateRequested', false);
|
|
|
|
$this->assertFalse(
|
|
app(DeploymentService::class)->updateRequested(),
|
|
'throttle must block the sentinel write',
|
|
);
|
|
$this->assertNull(Cache::get('clusev:update-in-progress'), 'no Redis flag when throttled');
|
|
}
|
|
|
|
public function test_request_update_does_not_redirect_when_nothing_to_update(): 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)
|
|
->assertNoRedirect();
|
|
}
|
|
|
|
public function test_request_update_does_not_redirect_when_sentinel_not_writable(): void
|
|
{
|
|
config()->set('clusev.version', '0.9.4');
|
|
Http::fake([self::TAGS_URL => Http::response([['name' => 'v0.9.6']])]);
|
|
|
|
$mock = \Mockery::mock(DeploymentService::class)->makePartial();
|
|
$mock->shouldReceive('requestUpdate')->andReturn(false);
|
|
$this->app->instance(DeploymentService::class, $mock);
|
|
|
|
Livewire::test(Index::class)
|
|
->call('requestUpdate')
|
|
->assertSet('updateRequested', false)
|
|
->assertNoRedirect();
|
|
}
|
|
|
|
// ── (b) /update-progress page ─────────────────────────────────────────────
|
|
|
|
public function test_update_progress_page_returns_200_for_authenticated_user(): void
|
|
{
|
|
$this->get(route('update.progress'))->assertOk();
|
|
}
|
|
|
|
public function test_update_progress_page_redirects_guests_to_login(): void
|
|
{
|
|
auth()->logout();
|
|
$this->get(route('update.progress'))->assertRedirect(route('login'));
|
|
}
|
|
|
|
public function test_update_progress_page_contains_heading(): void
|
|
{
|
|
$this->get(route('update.progress'))
|
|
->assertOk()
|
|
->assertSee('Update');
|
|
}
|
|
|
|
public function test_update_progress_page_has_no_livewire_scripts(): void
|
|
{
|
|
$body = $this->get(route('update.progress'))->content();
|
|
|
|
$this->assertStringNotContainsStringIgnoringCase(
|
|
'livewire',
|
|
$body,
|
|
'the update-progress page must contain no Livewire script tags — it must survive the backend going down',
|
|
);
|
|
}
|
|
|
|
public function test_update_progress_page_has_no_wire_attributes(): void
|
|
{
|
|
$body = $this->get(route('update.progress'))->content();
|
|
|
|
$this->assertStringNotContainsString(
|
|
'wire:',
|
|
$body,
|
|
'the update-progress page must contain no wire: attributes',
|
|
);
|
|
}
|
|
|
|
public function test_update_progress_page_polls_version_endpoint_in_its_js(): void
|
|
{
|
|
$body = $this->get(route('update.progress'))->content();
|
|
|
|
$this->assertStringContainsString(
|
|
"fetch('/version.json'",
|
|
$body,
|
|
'the inline JS must poll /version.json so the old container (200 on /up during the build) cannot trigger a premature redirect',
|
|
);
|
|
$this->assertStringNotContainsString(
|
|
"fetch('/up'",
|
|
$body,
|
|
'the page must NOT poll /up — the old container answers 200 there throughout the build (the 502 bug)',
|
|
);
|
|
}
|
|
|
|
public function test_version_endpoint_returns_running_version_as_json(): void
|
|
{
|
|
config()->set('clusev.version', '1.2.3');
|
|
|
|
$this->getJson('/version.json')
|
|
->assertOk()
|
|
->assertExactJson(['version' => '1.2.3']);
|
|
}
|
|
|
|
public function test_update_progress_page_embeds_the_from_version_baseline(): void
|
|
{
|
|
config()->set('clusev.version', '0.9.4');
|
|
|
|
$body = $this->get(route('update.progress', ['from' => '0.9.4']))->content();
|
|
|
|
// The pre-update version must reach the JS so completion can be gated on a CHANGE.
|
|
$this->assertStringContainsString('var fromVersion', $body);
|
|
$this->assertStringContainsString('0.9.4', $body);
|
|
}
|
|
|
|
public function test_update_progress_page_sanitises_the_from_version(): void
|
|
{
|
|
// A hostile ?from= must be stripped to the semver charset before it reaches the JS literal.
|
|
$body = $this->get(route('update.progress', ['from' => '"+alert(1)+"']))->content();
|
|
|
|
$this->assertStringNotContainsString('alert(1)', $body);
|
|
}
|
|
|
|
public function test_update_progress_page_contains_phase_labels(): void
|
|
{
|
|
$body = $this->get(route('update.progress'))->content();
|
|
|
|
// German labels (default locale in tests)
|
|
$this->assertStringContainsString('Update wird abgerufen', $body);
|
|
$this->assertStringContainsString('Image wird erstellt', $body);
|
|
$this->assertStringContainsString('Datenbank wird migriert', $body);
|
|
$this->assertStringContainsString('Dienste werden neu gestartet', $body);
|
|
}
|
|
|
|
// ── (c) real phase feed ───────────────────────────────────────────────────
|
|
|
|
public function test_update_progress_page_polls_the_phase_status_feed(): void
|
|
{
|
|
$body = $this->get(route('update.progress'))->content();
|
|
|
|
$this->assertStringContainsString(
|
|
"fetch('/update-status.json'",
|
|
$body,
|
|
'the page must poll the real phase feed so the checklist tracks actual progress',
|
|
);
|
|
}
|
|
|
|
public function test_update_status_returns_a_null_stage_when_no_feed_file(): void
|
|
{
|
|
@unlink(storage_path('app/restart-signal/update-phase.json'));
|
|
|
|
$this->getJson('/update-status.json')
|
|
->assertOk()
|
|
->assertJson(['stage' => null]);
|
|
}
|
|
|
|
public function test_update_status_returns_a_whitelisted_stage_from_the_feed(): void
|
|
{
|
|
$dir = storage_path('app/restart-signal');
|
|
@mkdir($dir, 0775, true);
|
|
file_put_contents($dir.'/update-phase.json', json_encode(['stage' => 'build', 'at' => 123]));
|
|
|
|
try {
|
|
$this->getJson('/update-status.json')
|
|
->assertOk()
|
|
->assertExactJson(['stage' => 'build', 'at' => 123]);
|
|
} finally {
|
|
@unlink($dir.'/update-phase.json');
|
|
}
|
|
}
|
|
|
|
public function test_update_status_rejects_an_unknown_stage(): void
|
|
{
|
|
$dir = storage_path('app/restart-signal');
|
|
@mkdir($dir, 0775, true);
|
|
file_put_contents($dir.'/update-phase.json', json_encode(['stage' => 'evil', 'at' => 1]));
|
|
|
|
try {
|
|
// Only the whitelisted macro-stages are ever echoed back.
|
|
$this->getJson('/update-status.json')
|
|
->assertOk()
|
|
->assertJson(['stage' => null]);
|
|
} finally {
|
|
@unlink($dir.'/update-phase.json');
|
|
}
|
|
}
|
|
|
|
// ── (c) resumable progress (survives a browser refresh) ───────────────────
|
|
|
|
public function test_request_update_writes_a_recent_start_marker(): void
|
|
{
|
|
config()->set('clusev.version', '0.9.4');
|
|
Http::fake([self::TAGS_URL => Http::response([['name' => 'v0.9.6']])]);
|
|
|
|
$before = now()->timestamp;
|
|
Livewire::test(Index::class)->call('requestUpdate');
|
|
|
|
$startedAt = app(DeploymentService::class)->updateStartedAt();
|
|
$this->assertIsInt($startedAt, 'requestUpdate must record when the update was requested');
|
|
$this->assertGreaterThanOrEqual($before, $startedAt);
|
|
$this->assertLessThanOrEqual(now()->timestamp + 1, $startedAt);
|
|
}
|
|
|
|
public function test_update_started_at_is_null_without_a_marker(): void
|
|
{
|
|
$this->assertNull(app(DeploymentService::class)->updateStartedAt());
|
|
}
|
|
|
|
public function test_clear_update_request_also_removes_the_start_marker(): 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');
|
|
$this->assertNotNull(app(DeploymentService::class)->updateStartedAt());
|
|
|
|
app(DeploymentService::class)->clearUpdateRequest();
|
|
$this->assertNull(app(DeploymentService::class)->updateStartedAt());
|
|
}
|
|
|
|
public function test_update_progress_embeds_the_start_marker_when_present(): void
|
|
{
|
|
$deployment = app(DeploymentService::class);
|
|
@mkdir(dirname($deployment->updateStartedPath()), 0775, true);
|
|
file_put_contents($deployment->updateStartedPath(), json_encode(['at' => 1700000000, 'from' => '0.9.4']));
|
|
|
|
$body = $this->get(route('update.progress'))->content();
|
|
|
|
$this->assertStringContainsString(
|
|
'var startedAt = 1700000000;',
|
|
$body,
|
|
'the page must embed the server-side update start so the elapsed timer survives a refresh',
|
|
);
|
|
}
|
|
|
|
public function test_update_progress_embeds_null_start_when_absent(): void
|
|
{
|
|
$body = $this->get(route('update.progress'))->content();
|
|
|
|
$this->assertStringContainsString('var startedAt = null;', $body);
|
|
}
|
|
|
|
public function test_update_progress_resumes_the_phase_from_the_feed_file(): void
|
|
{
|
|
$deployment = app(DeploymentService::class);
|
|
$dir = dirname($deployment->updateStartedPath());
|
|
@mkdir($dir, 0775, true);
|
|
$now = now()->timestamp;
|
|
file_put_contents($deployment->updateStartedPath(), json_encode(['at' => $now - 120, 'from' => '0.9.4']));
|
|
file_put_contents($dir.'/update-phase.json', json_encode(['stage' => 'restart', 'at' => $now - 90]));
|
|
|
|
try {
|
|
$body = $this->get(route('update.progress'))->content();
|
|
|
|
// restart = index 2: the checklist must initialise at phase 2 (not reset to 0), even
|
|
// though the stage was written 90s ago — far past the old 30s staleness window.
|
|
$this->assertStringContainsString('setPhaseActive(2);', $body);
|
|
// A fresh real stage drives the checklist from the feed — the time heuristic stays off
|
|
// so a large elapsed time on refresh cannot overshoot past the real phase.
|
|
$this->assertStringContainsString('var feedActive = true;', $body);
|
|
} finally {
|
|
@unlink($dir.'/update-phase.json');
|
|
}
|
|
}
|
|
|
|
public function test_update_status_ignores_a_stage_without_a_valid_timestamp(): void
|
|
{
|
|
$dir = storage_path('app/restart-signal');
|
|
@mkdir($dir, 0775, true);
|
|
// A whitelisted stage but NO timestamp must not pass as a current stage — otherwise a stale
|
|
// 'done' could drive a premature completion redirect on the progress page.
|
|
file_put_contents($dir.'/update-phase.json', json_encode(['stage' => 'done']));
|
|
|
|
try {
|
|
$this->getJson('/update-status.json')
|
|
->assertOk()
|
|
->assertJson(['stage' => null]);
|
|
} finally {
|
|
@unlink($dir.'/update-phase.json');
|
|
}
|
|
}
|
|
|
|
public function test_update_progress_ignores_a_phase_that_predates_this_update(): void
|
|
{
|
|
$deployment = app(DeploymentService::class);
|
|
$dir = dirname($deployment->updateStartedPath());
|
|
@mkdir($dir, 0775, true);
|
|
$now = now()->timestamp;
|
|
// The start marker is NEWER than the leftover stage (a 'done' from a previous run) — the page
|
|
// must NOT resume it: it stays at phase one with the feed off (heuristic drives) so the old
|
|
// 'done' can't trigger a premature finish.
|
|
file_put_contents($deployment->updateStartedPath(), json_encode(['at' => $now, 'from' => '0.9.4']));
|
|
file_put_contents($dir.'/update-phase.json', json_encode(['stage' => 'done', 'at' => $now - 300]));
|
|
|
|
try {
|
|
$body = $this->get(route('update.progress'))->content();
|
|
|
|
$this->assertStringContainsString('setPhaseActive(0);', $body);
|
|
$this->assertStringContainsString('var feedActive = false;', $body);
|
|
} finally {
|
|
@unlink($dir.'/update-phase.json');
|
|
}
|
|
}
|
|
}
|