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'); } } }