ADD Deployment csomagoló phase1 védelem, git olvasó és read-only commit/diff felület

Két commit közötti fájlok összegyűjtését előkészítő felület első fázisa: célkörnyezet
választás (e2e/d2d), commitlista, diff-előnézet figyelmeztetésekkel. Ez a fázis még
semmit nem ír a fájlrendszerre, a csomagolás a phase2-ben érkezik.

- DeploymentGuard: .env kapcsoló + hardkódolt stage whitelist + developer szerep +
  feature flag; a flag szándékosan nem biztonsági réteg, csak láthatóság-vezérlés
- GitRepository: csak olvasó wrapper, argumentum-tömbös Symfony Process (nincs shell),
  hash- és referencia-validáció, core.quotePath=false az ékezetes útvonalakhoz
- ChangeSetAnalyzer: kizárási lista, törlendő/másolandó szétválasztás (átnevezésnél
  mindkettő), figyelmeztetések migrációra, composer.lock-ra és a nem verziókövetett
  fordított assetekre

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
E98Developer 2026-08-16 06:45:09 +02:00
parent 8bcfeba0f9
commit 09eed3eeb3
10 changed files with 1530 additions and 0 deletions

View File

@ -0,0 +1,303 @@
<?php
namespace App\Filament\Pages;
use App\Services\Deployment\ChangeSetAnalyzer;
use App\Services\Deployment\DeploymentGuard;
use App\Services\Deployment\DeploymentTargets;
use App\Services\Deployment\GitRepository;
use Filament\Forms\Components\Select;
use Filament\Forms\Components\TextInput;
use Filament\Forms\Concerns\InteractsWithForms;
use Filament\Forms\Contracts\HasForms;
use Filament\Notifications\Notification;
use Filament\Pages\Page;
use Filament\Schemas\Components\Grid;
use Filament\Schemas\Schema;
use Livewire\Attributes\Computed;
use RuntimeException;
/**
* Deployment csomagoló - fázis 1: célkörnyezet, commitlista és diff-előnézet.
*
* Ez az oldal (a fázis 1-ben) semmit nem ír a fájlrendszerre, csak olvassa a git
* történetet. A tényleges csomagolás a fázis 2-ben érkezik.
*
* A hozzáférést a DeploymentGuard dönti el (env kapcsoló + stage whitelist +
* developer szerep + feature flag), és a mount()-on kívül minden akció elején
* újra lefut - a felület elrejtése önmagában nem védelem.
*/
class DeploymentPackage extends Page implements HasForms
{
use InteractsWithForms;
protected static string|\BackedEnum|null $navigationIcon = 'heroicon-o-rocket-launch';
protected string $view = 'filament.pages.deployment-package';
protected static ?string $title = 'Deployment csomagoló';
protected static ?string $navigationLabel = 'Deployment csomagoló';
protected static bool $shouldRegisterNavigation = false;
/** @var array<string, mixed>|null */
public ?array $data = [];
public ?string $fromCommit = null;
public ?string $toCommit = null;
public ?string $gitError = null;
public static function canAccess(): bool
{
return app(DeploymentGuard::class)->isAllowed(auth()->user());
}
public function mount(): void
{
app(DeploymentGuard::class)->ensureAllowed(auth()->user());
/** @var array<int, int> $limits */
$limits = (array) config('deployment.commit_limits', [50]);
$this->form->fill([
'target' => app(DeploymentTargets::class)->defaultKey(),
'branch' => $this->defaultBranch(),
'limit' => $limits[0] ?? 50,
'search' => null,
]);
}
public function form(Schema $form): Schema
{
/** @var array<int, int> $limits */
$limits = (array) config('deployment.commit_limits', [50]);
return $form
->schema([
Grid::make(4)
->schema([
Select::make('target')
->label('Célkörnyezet')
->options(app(DeploymentTargets::class)->options())
->required()
->live()
->helperText(fn (): ?string => $this->target()['remote_path'] ?? null),
Select::make('branch')
->label('Branch')
->options($this->branchOptions())
->required()
->searchable()
->live()
->afterStateUpdated(fn () => $this->clearRange()),
Select::make('limit')
->label('Commitok száma')
->options(array_combine($limits, $limits))
->required()
->live(),
TextInput::make('search')
->label('Keresés')
->placeholder('hash, üzenet vagy szerző')
->helperText('A betöltött listán belül szűr.')
->live(debounce: 400),
]),
])
->statePath('data');
}
public function selectFrom(string $hash): void
{
app(DeploymentGuard::class)->ensureAllowed(auth()->user());
$this->fromCommit = $this->resolveOrWarn($hash);
}
public function selectTo(string $hash): void
{
app(DeploymentGuard::class)->ensureAllowed(auth()->user());
$this->toCommit = $this->resolveOrWarn($hash);
}
public function swapRange(): void
{
app(DeploymentGuard::class)->ensureAllowed(auth()->user());
[$this->fromCommit, $this->toCommit] = [$this->toCommit, $this->fromCommit];
}
public function clearRange(): void
{
$this->fromCommit = null;
$this->toCommit = null;
}
/**
* @return array{key:string, label:string, domain:string, remote_path:string, expected_branch:?string}|null
*/
public function target(): ?array
{
return app(DeploymentTargets::class)->find($this->data['target'] ?? null);
}
/**
* @return array<int, array{hash:string, short:string, author:string, date:string, subject:string, file_count:?int}>
*/
#[Computed]
public function commits(): array
{
$branch = $this->data['branch'] ?? null;
if (! $branch) {
return [];
}
try {
$commits = app(GitRepository::class)->commits($branch, (int) ($this->data['limit'] ?? 50));
} catch (RuntimeException $exception) {
$this->gitError = $exception->getMessage();
return [];
}
$search = trim((string) ($this->data['search'] ?? ''));
if ($search === '') {
return $commits;
}
return array_values(array_filter(
$commits,
fn (array $commit): bool => str_contains(mb_strtolower($commit['subject'].' '.$commit['author'].' '.$commit['hash']), mb_strtolower($search)),
));
}
/**
* A kijelölt tartomány elemzése, vagy null, ha még nincs két commit kiválasztva.
*
* @return array{
* files: array<int, array{status:string, path:string, old_path:?string, excluded:bool, action:string}>,
* copied: array<int, string>,
* deleted: array<int, string>,
* counts: array{total:int, copied:int, deleted:int, excluded:int},
* warnings: array<int, array{level:string, title:string, body:string}>
* }|null
*/
#[Computed]
public function changeSet(): ?array
{
if (! $this->fromCommit || ! $this->toCommit) {
return null;
}
$repository = app(GitRepository::class);
try {
$changedFiles = $repository->changedFiles($this->fromCommit, $this->toCommit);
} catch (RuntimeException $exception) {
$this->gitError = $exception->getMessage();
return null;
}
$changeSet = app(ChangeSetAnalyzer::class)->analyze(
$changedFiles,
$this->target(),
$this->data['branch'] ?? null,
);
if ($this->fromCommit === $this->toCommit) {
array_unshift($changeSet['warnings'], [
'level' => 'warning',
'title' => 'Azonos commitok',
'body' => 'A kezdő és a záró commit ugyanaz, így a tartomány üres.',
]);
} elseif (! $repository->isAncestor($this->fromCommit, $this->toCommit)) {
array_unshift($changeSet['warnings'], [
'level' => 'danger',
'title' => 'Fordított vagy szétágazó tartomány',
'body' => 'A kezdő commit nem őse a zárónak, ezért a lista nem a "mi került bele azóta" kérdésre válaszol. Ellenőrizd a sorrendet (Csere gomb), vagy azt, hogy ugyanazon az ágon vagy-e.',
]);
}
return $changeSet;
}
/**
* A kijelölt tartományba eső commitok - csak a lista kiemeléséhez.
*
* @return array<int, string>
*/
#[Computed]
public function highlightedHashes(): array
{
if (! $this->fromCommit || ! $this->toCommit) {
return array_values(array_filter([$this->fromCommit, $this->toCommit]));
}
$hashes = array_column($this->commits(), 'hash');
$fromIndex = array_search($this->fromCommit, $hashes, true);
$toIndex = array_search($this->toCommit, $hashes, true);
if ($fromIndex === false || $toIndex === false) {
return array_values(array_filter([$this->fromCommit, $this->toCommit]));
}
// A lista fentről lefelé a legfrissebbtől halad, tehát a záró commit van előrébb.
[$start, $end] = $fromIndex <= $toIndex ? [$fromIndex, $toIndex] : [$toIndex, $fromIndex];
return array_slice($hashes, $start, $end - $start + 1);
}
/**
* @return array<string, string>
*/
public function branchOptions(): array
{
try {
$branches = app(GitRepository::class)->branches();
} catch (RuntimeException $exception) {
$this->gitError = $exception->getMessage();
return [];
}
return array_combine($branches, $branches) ?: [];
}
public function isRepositoryAvailable(): bool
{
return app(GitRepository::class)->isAvailable();
}
private function defaultBranch(): ?string
{
$repository = app(GitRepository::class);
$current = $repository->currentBranch();
if ($current) {
return $current;
}
return $this->branchOptions() === [] ? null : array_key_first($this->branchOptions());
}
private function resolveOrWarn(string $hash): ?string
{
$resolved = app(GitRepository::class)->resolveCommit($hash);
if (! $resolved) {
Notification::make()
->title('Ismeretlen commit')
->body('A kiválasztott commit nem található a repóban.')
->danger()
->send();
return null;
}
return $resolved;
}
}

View File

@ -0,0 +1,170 @@
<?php
namespace App\Services\Deployment;
use Illuminate\Support\Str;
/**
* A git diff nyers kimenetéből állítja elő a csomagolható halmazt és a figyelmeztetéseket.
*
* Szándékosan nem hív gitet és nem ír fájlt: tisztán a listát és a szabályokat kezeli,
* így a fázis 2 csomagolója ugyanezt az eredményt tudja majd felhasználni.
*/
class ChangeSetAnalyzer
{
/**
* @param array<int, array{status:string, path:string, old_path:?string}> $changedFiles
* @param array{key:string, label:string, domain:string, remote_path:string, expected_branch:?string}|null $target
* @return array{
* files: array<int, array{status:string, path:string, old_path:?string, excluded:bool, action:string}>,
* copied: array<int, string>,
* deleted: array<int, string>,
* counts: array{total:int, copied:int, deleted:int, excluded:int},
* warnings: array<int, array{level:string, title:string, body:string}>
* }
*/
public function analyze(array $changedFiles, ?array $target = null, ?string $branch = null): array
{
/** @var array<int, string> $excludePatterns */
$excludePatterns = (array) config('deployment.exclude', []);
$files = [];
$copied = [];
$deleted = [];
$excludedCount = 0;
foreach ($changedFiles as $file) {
$excluded = Str::is($excludePatterns, $file['path']);
// Törlés esetén nincs mit másolni: a fájl a célgépen létezik, itt már nem.
// Átnevezésnél mindkettő kell - az új útvonal másolandó, a régi törlendő.
$action = $excluded
? 'skip'
: ($file['status'] === 'D' ? 'delete' : 'copy');
if ($excluded) {
$excludedCount++;
} elseif ($action === 'delete') {
$deleted[] = $file['path'];
} else {
$copied[] = $file['path'];
if ($file['old_path'] !== null && ! Str::is($excludePatterns, $file['old_path'])) {
$deleted[] = $file['old_path'];
}
}
$files[] = array_merge($file, ['excluded' => $excluded, 'action' => $action]);
}
return [
'files' => $files,
'copied' => $copied,
'deleted' => $deleted,
'counts' => [
'total' => count($files),
'copied' => count($copied),
'deleted' => count($deleted),
'excluded' => $excludedCount,
],
'warnings' => $this->warnings($copied, $deleted, $excludedCount, $target, $branch),
];
}
/**
* @param array<int, string> $copied
* @param array<int, string> $deleted
* @param array{key:string, label:string, domain:string, remote_path:string, expected_branch:?string}|null $target
* @return array<int, array{level:string, title:string, body:string}>
*/
private function warnings(array $copied, array $deleted, int $excludedCount, ?array $target, ?string $branch): array
{
$warnings = [];
$maxFiles = (int) config('deployment.max_files', 500);
if ($copied === [] && $deleted === []) {
$warnings[] = [
'level' => 'info',
'title' => 'Nincs csomagolható változás',
'body' => 'A két commit között nincs olyan fájl, ami a célgépre kerülne.',
];
}
if (count($copied) > $maxFiles) {
$warnings[] = [
'level' => 'danger',
'title' => sprintf('Túl sok fájl (%d db, limit %d)', count($copied), $maxFiles),
'body' => 'Ekkora tartomány kézi másolással már nehezen ellenőrizhető. Szűkítsd a commit-tartományt, vagy bontsd több csomagra.',
];
}
$migrations = $this->matching($copied, 'migration');
if ($migrations !== []) {
$warnings[] = [
'level' => 'warning',
'title' => sprintf('Migráció a csomagban (%d db)', count($migrations)),
'body' => 'A fájlok felmásolása után a célgépen le kell futtatni: php artisan migrate',
];
}
if ($this->matching($copied, 'composer') !== []) {
$warnings[] = [
'level' => 'warning',
'title' => 'Függőség változott (composer)',
'body' => 'A vendor/ nincs verziókövetve, ezért nem kerül a csomagba - a célgépen composer install szükséges.',
];
}
if ($this->matching($copied, 'asset') !== []) {
$warnings[] = [
'level' => 'warning',
'title' => 'Frontend forrás változott',
'body' => 'A fordított assetek (public/build, public/js, public/css, mix-manifest.json) a .gitignore miatt NEM kerülnek a csomagba. Futtass npm run build-ot, és a fázis 4-től külön csatolhatók lesznek.',
];
}
if ($deleted !== []) {
$warnings[] = [
'level' => 'warning',
'title' => sprintf('Törlendő fájl a célgépen (%d db)', count($deleted)),
'body' => 'Ezeket a másolás nem intézi el, kézzel kell törölni - a csomag _TORLENDO.txt fájlja fogja tartalmazni őket.',
];
}
if ($excludedCount > 0) {
$warnings[] = [
'level' => 'info',
'title' => sprintf('Kihagyott fájl (%d db)', $excludedCount),
'body' => 'A kizárási lista (.env, storage/, vendor/, node_modules/, .git*) alapján ezek soha nem kerülnek csomagba.',
];
}
if ($target && $target['expected_branch'] && $branch && $branch !== $target['expected_branch']) {
$warnings[] = [
'level' => 'warning',
'title' => 'Nem a szokásos branch',
'body' => sprintf(
'A(z) "%s" környezetre általában a %s branchről megy csomag, most viszont a %s van kiválasztva.',
$target['label'],
$target['expected_branch'],
$branch,
),
];
}
return $warnings;
}
/**
* @param array<int, string> $paths
* @return array<int, string>
*/
private function matching(array $paths, string $group): array
{
/** @var array<int, string> $patterns */
$patterns = (array) config('deployment.attention.'.$group, []);
return array_values(array_filter($paths, fn (string $path): bool => Str::is($patterns, $path)));
}
}

View File

@ -0,0 +1,69 @@
<?php
namespace App\Services\Deployment;
use App\Models\User;
use Laravel\Pennant\Feature;
/**
* A deployment csomagoló hozzáférés-ellenőrzése egy helyen.
*
* Az alkalmazás kódja manuális fájlmásolással kerül a szerverekre, tehát ez az oldal
* fizikailag ott lesz az éles környezetben is. Ezért négy, egymástól független feltétel
* van, és mindet MINDEN belépési ponton ellenőrizni kell (oldal mount, akciók, később a
* csomagoló service) - ha csak a felületet védjük, az látszatvédelem.
*
* A négyből a tényleges védelmet az .env kapcsoló és a stage whitelist adja, a
* jogosultságot a szerepkör. A feature flag NEM biztonsági réteg: a menü-láthatóságot
* vezérli, és lehetővé teszi a deploy nélküli kikapcsolást, illetve a név szerinti
* átadást (feature_flag_overrides) developer szerep osztogatása nélkül.
*/
class DeploymentGuard
{
public function isAllowed(?User $user = null): bool
{
return $this->denialReason($user) === null;
}
/**
* Az első nem teljesülő feltétel magyarázata, vagy null, ha minden rendben.
*/
public function denialReason(?User $user = null): ?string
{
if (! config('deployment.enabled')) {
return 'A deployment csomagoló ki van kapcsolva (DEPLOYMENT_PACKAGE_ENABLED).';
}
/** @var array<int, string> $stages */
$stages = (array) config('deployment.stages', []);
if (! in_array((string) config('app.stage'), $stages, true)) {
return sprintf(
'A deployment csomagoló csak fejlesztői környezetben használható (engedélyezett stage: %s, jelenlegi: %s).',
implode(', ', $stages),
(string) config('app.stage'),
);
}
if (! $user) {
return 'Bejelentkezés szükséges.';
}
if (! $user->hasRole((string) config('deployment.role'))) {
return 'A deployment csomagoló csak fejlesztői szerepkörrel érhető el.';
}
if (! Feature::for($user)->active((string) config('deployment.feature_flag'))) {
return 'A deployment csomagoló feature flag nincs bekapcsolva erre a felhasználóra.';
}
return null;
}
public function ensureAllowed(?User $user = null): void
{
if ($reason = $this->denialReason($user)) {
abort(403, $reason);
}
}
}

View File

@ -0,0 +1,54 @@
<?php
namespace App\Services\Deployment;
/**
* A célkörnyezetek (e2e, d2d) feloldása a config/deployment.php-ból.
*
* A target nem utólagos címke: már a csomagoláskor rögzül, mert befolyásolja a
* mappanevet, a teendők listáját, a kezdő commit előtöltését és a naplót is.
*/
class DeploymentTargets
{
/**
* @return array<string, array{key:string, label:string, domain:string, remote_path:string, expected_branch:?string}>
*/
public function all(): array
{
/** @var array<string, array<string, string>> $targets */
$targets = (array) config('deployment.targets', []);
$resolved = [];
foreach ($targets as $key => $target) {
$resolved[$key] = array_merge(
['label' => $key, 'domain' => '', 'remote_path' => '', 'expected_branch' => null],
$target,
['key' => $key],
);
}
return $resolved;
}
/**
* @return array{key:string, label:string, domain:string, remote_path:string, expected_branch:?string}|null
*/
public function find(?string $key): ?array
{
return $key ? ($this->all()[$key] ?? null) : null;
}
/**
* @return array<string, string>
*/
public function options(): array
{
return array_map(fn (array $target): string => $target['label'], $this->all());
}
public function defaultKey(): ?string
{
return array_key_first($this->all());
}
}

View File

@ -0,0 +1,273 @@
<?php
namespace App\Services\Deployment;
use RuntimeException;
use Symfony\Component\Process\Process;
/**
* Csak olvasó git wrapper a deployment csomagolóhoz.
*
* Minden hívás argumentum-tömbbel megy a Symfony Process-nek, soha nem shell-stringgel,
* így a felületről érkező érték nem tud parancsot injektálni. Ettől függetlenül minden
* bemenetet külön is validálunk: a git a kötőjellel kezdődő értéket kapcsolóként
* értelmezné, a `..` pedig commit-tartományt jelent, nem fájlnevet.
*/
class GitRepository
{
/** Mezőelválasztó a git log formátumban (ASCII unit separator). */
private const FIELD_SEPARATOR = "\x1f";
/** Rekordelválasztó a git log formátumban (ASCII record separator). */
private const RECORD_SEPARATOR = "\x1e";
private const COMMIT_PATTERN = '/^[0-9a-f]{7,40}$/';
private const REFERENCE_PATTERN = '#^[A-Za-z0-9][A-Za-z0-9._/-]*$#';
public function isAvailable(): bool
{
try {
$this->run(['rev-parse', '--git-dir']);
return true;
} catch (RuntimeException) {
return false;
}
}
public function currentBranch(): ?string
{
try {
$branch = trim($this->run(['rev-parse', '--abbrev-ref', 'HEAD']));
} catch (RuntimeException) {
return null;
}
return ($branch === '' || $branch === 'HEAD') ? null : $branch;
}
/**
* @return array<int, string>
*/
public function branches(): array
{
$output = $this->run([
'for-each-ref',
'--format=%(refname:short)',
'--sort=-committerdate',
'refs/heads',
'refs/remotes',
]);
return collect(explode("\n", $output))
->map(fn (string $line): string => trim($line))
->filter()
// Az "origin/HEAD" csak egy mutató az alapértelmezett branchre, nem önálló ág.
->reject(fn (string $branch): bool => str_ends_with($branch, '/HEAD'))
->unique()
->values()
->all();
}
/**
* Commitlista egy referenciáról, legfrissebbtől visszafelé.
*
* A --shortstat miatt minden commit után megjelenik a "N files changed" sor is,
* ezt az utolsó mezőből (a tárgyból) bányásszuk ki - így nem kell commitonként
* külön git hívás a fájlszámhoz.
*
* @return array<int, array{hash:string, short:string, author:string, date:string, subject:string, file_count:?int}>
*/
public function commits(string $reference, int $limit): array
{
$this->assertReference($reference);
$format = self::RECORD_SEPARATOR.implode(self::FIELD_SEPARATOR, ['%H', '%h', '%an', '%aI', '%s']);
$output = $this->run([
'log',
'--max-count='.max(1, min($limit, 500)),
'--no-merges',
'--shortstat',
'--pretty=format:'.$format,
$reference,
'--',
]);
$commits = [];
foreach (explode(self::RECORD_SEPARATOR, $output) as $record) {
if (trim($record) === '') {
continue;
}
$fields = explode(self::FIELD_SEPARATOR, $record, 5);
if (count($fields) < 5) {
continue;
}
[$hash, $short, $author, $date, $tail] = $fields;
$commits[] = [
'hash' => $hash,
'short' => $short,
'author' => $author,
'date' => $date,
'subject' => trim(explode("\n", $tail, 2)[0]),
'file_count' => preg_match('/(\d+)\s+files?\s+changed/', $tail, $matches) === 1
? (int) $matches[1]
: null,
];
}
return $commits;
}
/**
* Teljes hash, ha a megadott érték létező commitra mutat - különben null.
*/
public function resolveCommit(string $hash): ?string
{
if (preg_match(self::COMMIT_PATTERN, $hash) !== 1) {
return null;
}
try {
$resolved = trim($this->run(['rev-parse', '--verify', '--quiet', $hash.'^{commit}']));
} catch (RuntimeException) {
return null;
}
return $resolved === '' ? null : $resolved;
}
/**
* Őse-e az első commit a másodiknak? (Fordított tartomány felismeréséhez.)
*/
public function isAncestor(string $ancestor, string $descendant): bool
{
if (! $this->resolveCommit($ancestor) || ! $this->resolveCommit($descendant)) {
return false;
}
// A merge-base 1-es kilépési kóddal jelzi a "nem őse" esetet, ez nem hiba,
// ezért itt nem a run() dobó változatát használjuk.
return $this->process(['merge-base', '--is-ancestor', $ancestor, $descendant])->run() === 0;
}
/**
* A két commit között változott fájlok (from kizárva, to beleértve).
*
* @return array<int, array{status:string, path:string, old_path:?string}>
*/
public function changedFiles(string $from, string $to): array
{
$fromHash = $this->resolveCommit($from);
$toHash = $this->resolveCommit($to);
if (! $fromHash || ! $toHash) {
throw new RuntimeException('Ismeretlen commit azonosító.');
}
// A core.quotePath=false nélkül a git a nem-ASCII fájlneveket idézőjelbe teszi
// és oktálisan escape-eli ("app/\303\251kezet.php") - ékezetes útvonalaknál ez
// használhatatlan lenne.
$output = $this->run([
'-c', 'core.quotePath=false',
'diff',
'--name-status',
'--find-renames',
$fromHash.'..'.$toHash,
'--',
]);
$files = [];
foreach (explode("\n", $output) as $line) {
$line = rtrim($line, "\r\n");
if ($line === '') {
continue;
}
$parts = explode("\t", $line);
$status = strtoupper(substr($parts[0], 0, 1));
// Átnevezés (R) és másolás (C) esetén két útvonal jön: régi és új.
$isTwoPath = in_array($status, ['R', 'C'], true) && count($parts) >= 3;
$path = $isTwoPath ? $parts[2] : ($parts[1] ?? '');
$oldPath = $isTwoPath ? $parts[1] : null;
if (! $this->isSafePath($path) || ($oldPath !== null && ! $this->isSafePath($oldPath))) {
continue;
}
$files[] = [
'status' => $status,
'path' => $path,
'old_path' => $oldPath,
];
}
usort($files, fn (array $a, array $b): int => strcmp($a['path'], $b['path']));
return $files;
}
/**
* A repo gyökeréből ki nem mutató, relatív útvonal-e.
*/
private function isSafePath(string $path): bool
{
if ($path === '' || str_starts_with($path, '/') || str_contains($path, "\0")) {
return false;
}
// Windows meghajtó-előtag (C:/...) és szülőkönyvtár-hivatkozás sem fordulhat elő
// valódi git útvonalban, viszont fájlkiírásnál kitörhetne a célmappából.
return preg_match('#^[A-Za-z]:#', $path) !== 1
&& preg_match('#(^|/)\.\.(/|$)#', $path) !== 1;
}
private function assertReference(string $reference): void
{
if (preg_match(self::REFERENCE_PATTERN, $reference) !== 1 || str_contains($reference, '..')) {
throw new RuntimeException(sprintf('Érvénytelen git referencia: %s', $reference));
}
}
/**
* @param array<int, string> $arguments
*/
private function process(array $arguments): Process
{
return new Process(
array_merge([(string) config('deployment.git_binary') ?: 'git'], $arguments),
(string) config('deployment.repo_path') ?: base_path(),
null,
null,
(float) config('deployment.timeout', 60),
);
}
/**
* @param array<int, string> $arguments
*/
private function run(array $arguments): string
{
$process = $this->process($arguments);
$process->run();
if (! $process->isSuccessful()) {
throw new RuntimeException(sprintf(
'A git parancs hibára futott (%s): %s',
implode(' ', $arguments),
trim($process->getErrorOutput()) ?: trim($process->getOutput()),
));
}
return $process->getOutput();
}
}

127
config/deployment.php Normal file
View File

@ -0,0 +1,127 @@
<?php
return [
/*
|--------------------------------------------------------------------------
| kapcsoló (1. védelmi réteg)
|--------------------------------------------------------------------------
|
| A deployment csomagoló KIZÁRÓLAG fejlesztői gépen futhat. Mivel az
| alkalmazás kódja manuális fájlmásolással kerül a szerverekre, ez az
| oldal fizikailag ott lesz minden környezeten - ezért a bekapcsolás
| .env-ből történik, ami viszont nincs verziókövetve és nem másoljuk.
|
*/
'enabled' => env('DEPLOYMENT_PACKAGE_ENABLED', false),
/*
|--------------------------------------------------------------------------
| Stage whitelist (2. védelmi réteg)
|--------------------------------------------------------------------------
|
| Szándékosan hardkódolt lista, nem env-ből jön: ez fogja meg azt az esetet,
| ha valaki a fenti kapcsolót tévedésből bemásolja egy szerver .env-jébe.
|
*/
'stages' => ['local'],
/*
|--------------------------------------------------------------------------
| Jogosultság (3. réteg) és láthatóság (4. réteg)
|--------------------------------------------------------------------------
|
| A feature flag NEM biztonsági réteg - a védelmet a fenti kettő és a
| szerepkör adja. A flag a menü-láthatóságot vezérli, illetve deploy nélküli
| kikapcsolást és név szerinti átadást tesz lehetővé (feature_flag_overrides).
|
*/
'role' => 'developer',
'feature_flag' => 'DeploymentPackage',
/*
|--------------------------------------------------------------------------
| Git
|--------------------------------------------------------------------------
*/
'repo_path' => base_path(),
'git_binary' => env('DEPLOYMENT_GIT_BINARY', 'git'),
'timeout' => 60,
/*
|--------------------------------------------------------------------------
| Csomagolás
|--------------------------------------------------------------------------
*/
'output_path' => env('DEPLOYMENT_OUTPUT_PATH', storage_path('app/private/deployments')),
'max_files' => 500,
'commit_limits' => [50, 100, 200],
/*
| Ezek soha nem kerülnek csomagba, akkor sem, ha a diffben szerepelnének.
| A minták Str::is() szintaxisúak, a repo gyökeréhez képest relatív úton.
*/
'exclude' => [
'.env',
'.env.*',
'auth.json',
'.git*',
'storage/*',
'vendor/*',
'node_modules/*',
],
/*
| Fájlok, amik csomagolhatók, de külön figyelmeztetést érdemelnek, mert
| önmagukban nem elegendők (kell melléjük egy parancs a célgépen).
*/
'attention' => [
'migration' => ['database/migrations/*'],
'composer' => ['composer.json', 'composer.lock'],
'asset' => [
'resources/js/*',
'resources/css/*',
'resources/modern/*',
'vite.config.js',
'webpack.mix.js',
'package.json',
'package-lock.json',
],
],
/*
|--------------------------------------------------------------------------
| Célkörnyezetek
|--------------------------------------------------------------------------
|
| Az expected_branch csak figyelmeztetést vezérel, nem tiltást.
| A remote_path a shared storage útvonala (ld. docs/cicd-context.md).
|
*/
'targets' => [
'e2e' => [
'label' => 'e2e — t2t éles',
'domain' => 'e2e.emegrendeles.hu',
'remote_path' => '/delirest/test.t2t.emegrendeles.hu/app/',
'expected_branch' => 'main',
],
'd2d' => [
'label' => 'd2d — fejlesztői',
'domain' => 'd2d.emegrendeles.hu',
'remote_path' => '/delirest/d2d.emegrendeles.hu/app/',
'expected_branch' => 'test',
],
],
];

View File

@ -40,6 +40,13 @@
['displayName' => 'Egyéni felülbírálások', 'link' => \App\Filament\Resources\FeatureFlagOverrides\FeatureFlagOverrideResource::getUrl()], ['displayName' => 'Egyéni felülbírálások', 'link' => \App\Filament\Resources\FeatureFlagOverrides\FeatureFlagOverrideResource::getUrl()],
]]; ]];
} }
// A deployment csomagoló kizárólag fejlesztői gépen érhető el (DeploymentGuard:
// .env kapcsoló + stage whitelist + developer szerep + feature flag), ezért a
// menüpont a szervereken akkor sem jelenik meg, ha a kód oda is felmásolódik.
if (\App\Filament\Pages\DeploymentPackage::canAccess()) {
$modules[] = ['name' => 'deploymentPackage', 'displayName' => 'Deployment', 'icon' => 'upload', 'link' => \App\Filament\Pages\DeploymentPackage::getUrl(), 'roles' => ['root','developer']];
}
@endphp @endphp
<nav class="fi-speed-button-nav flex items-center h-full ml-4 w-full"> <nav class="fi-speed-button-nav flex items-center h-full ml-4 w-full">

View File

@ -0,0 +1,225 @@
@php
// A fázis 1 felülete csak olvas: a git történetet listázza és a kijelölt tartomány
// diffjét mutatja. A számított property-ket egy helyen kérjük le, hogy a hibabanner
// (gitError) már a renderelés elején beállított állapotot lássa.
$commits = $this->commits;
$changeSet = $this->changeSet;
$highlighted = $this->highlightedHashes;
$target = $this->target();
$statusStyles = [
'A' => ['label' => 'új', 'class' => 'bg-emerald-100 text-emerald-800 dark:bg-emerald-900 dark:text-emerald-200'],
'M' => ['label' => 'módosult', 'class' => 'bg-amber-100 text-amber-800 dark:bg-amber-900 dark:text-amber-200'],
'D' => ['label' => 'törölt', 'class' => 'bg-red-100 text-red-800 dark:bg-red-900 dark:text-red-200'],
'R' => ['label' => 'átnevezett', 'class' => 'bg-blue-100 text-blue-800 dark:bg-blue-900 dark:text-blue-200'],
'C' => ['label' => 'másolt', 'class' => 'bg-blue-100 text-blue-800 dark:bg-blue-900 dark:text-blue-200'],
];
$warningStyles = [
'danger' => 'border-red-300 bg-red-50 text-red-900 dark:border-red-800 dark:bg-red-950 dark:text-red-200',
'warning' => 'border-amber-300 bg-amber-50 text-amber-900 dark:border-amber-800 dark:bg-amber-950 dark:text-amber-200',
'info' => 'border-gray-300 bg-gray-50 text-gray-800 dark:border-gray-700 dark:bg-gray-900 dark:text-gray-300',
];
$cardClass = 'p-6 bg-white border border-gray-200 rounded-lg shadow dark:bg-gray-800 dark:border-gray-700';
$buttonClass = 'px-2 py-1 text-xs font-semibold rounded border transition';
@endphp
<x-filament-panels::page>
<div class="space-y-6">
<div class="{{ $cardClass }}">
<h5 class="mb-2 text-2xl font-bold tracking-tight text-gray-900 dark:text-white">Deployment csomagoló</h5>
<p class="font-normal text-gray-700 dark:text-gray-400">
Válaszd ki a célkörnyezetet, majd két commitot: a köztük változott fájlok kerülnek majd a csomagba
(a kezdő commit kizárva, a záró beleértve).
</p>
<p class="mt-2 text-sm text-gray-500 dark:text-gray-400">
Ez a felület jelenleg <strong>csak olvas</strong> a csomag előállítása a következő fázisban készül el.
</p>
</div>
@if ($this->gitError)
<div class="p-4 border rounded-lg {{ $warningStyles['danger'] }}">
<p class="font-semibold">Git hiba</p>
<p class="mt-1 text-sm">{{ $this->gitError }}</p>
</div>
@elseif (! $this->isRepositoryAvailable())
<div class="p-4 border rounded-lg {{ $warningStyles['danger'] }}">
<p class="font-semibold">Nem érhető el git repó</p>
<p class="mt-1 text-sm">
A(z) <code>{{ config('deployment.repo_path') }}</code> útvonalon nincs git working copy,
vagy a git parancs nem található.
</p>
</div>
@endif
<div class="{{ $cardClass }}">
{{ $this->form }}
</div>
<div class="{{ $cardClass }}">
<div class="flex flex-wrap items-center justify-between gap-4">
<div class="flex flex-wrap items-center gap-3 text-sm">
<span class="text-gray-500 dark:text-gray-400">Tartomány:</span>
@if ($this->fromCommit)
<code class="px-2 py-1 rounded bg-gray-100 dark:bg-gray-900">{{ substr($this->fromCommit, 0, 7) }}</code>
@else
<span class="text-gray-400">nincs kezdő commit</span>
@endif
<span class="text-gray-400"></span>
@if ($this->toCommit)
<code class="px-2 py-1 rounded bg-gray-100 dark:bg-gray-900">{{ substr($this->toCommit, 0, 7) }}</code>
@else
<span class="text-gray-400">nincs záró commit</span>
@endif
@if ($changeSet)
<span class="text-gray-500 dark:text-gray-400">
· {{ $changeSet['counts']['copied'] }} másolandó
@if ($changeSet['counts']['deleted'] > 0)
, {{ $changeSet['counts']['deleted'] }} törlendő
@endif
</span>
@endif
</div>
<div class="flex items-center gap-2">
<button type="button" wire:click="swapRange"
class="{{ $buttonClass }} border-gray-300 text-gray-700 hover:bg-gray-100 dark:border-gray-600 dark:text-gray-200 dark:hover:bg-gray-700">
Csere
</button>
<button type="button" wire:click="clearRange"
class="{{ $buttonClass }} border-gray-300 text-gray-700 hover:bg-gray-100 dark:border-gray-600 dark:text-gray-200 dark:hover:bg-gray-700">
Törlés
</button>
</div>
</div>
@if ($target)
<p class="mt-3 text-sm text-gray-500 dark:text-gray-400">
Cél: <strong>{{ $target['label'] }}</strong> a fájlok ide kerülnek majd feltöltésre:
<code>{{ $target['remote_path'] }}</code>
</p>
@endif
</div>
<div class="{{ $cardClass }}">
<h6 class="mb-4 text-lg font-semibold text-gray-900 dark:text-white">
Commitok
<span class="ml-1 text-sm font-normal text-gray-500 dark:text-gray-400">({{ count($commits) }} db)</span>
</h6>
@if ($commits === [])
<p class="text-sm text-gray-500 dark:text-gray-400">Nincs megjeleníthető commit.</p>
@else
<div class="overflow-x-auto">
<table class="w-full text-sm text-left">
<thead class="text-xs uppercase text-gray-600 border-b border-gray-200 dark:text-gray-300 dark:border-gray-700">
<tr>
<th class="py-2 pr-4">Kijelölés</th>
<th class="py-2 pr-4">Hash</th>
<th class="py-2 pr-4">Dátum</th>
<th class="py-2 pr-4">Szerző</th>
<th class="py-2 pr-4">Üzenet</th>
<th class="py-2 text-right">Fájl</th>
</tr>
</thead>
<tbody>
@foreach ($commits as $commit)
@php
$isFrom = $this->fromCommit === $commit['hash'];
$isTo = $this->toCommit === $commit['hash'];
$inRange = in_array($commit['hash'], $highlighted, true);
@endphp
<tr wire:key="commit-{{ $commit['hash'] }}"
class="border-b border-gray-100 dark:border-gray-700 {{ $inRange ? 'bg-primary-50 dark:bg-gray-900' : '' }}">
<td class="py-2 pr-4 whitespace-nowrap">
<button type="button" wire:click="selectFrom('{{ $commit['hash'] }}')"
class="{{ $buttonClass }} {{ $isFrom ? 'border-primary-600 bg-primary-600 text-white' : 'border-gray-300 text-gray-700 hover:bg-gray-100 dark:border-gray-600 dark:text-gray-200 dark:hover:bg-gray-700' }}">
Kezdő
</button>
<button type="button" wire:click="selectTo('{{ $commit['hash'] }}')"
class="{{ $buttonClass }} ml-1 {{ $isTo ? 'border-primary-600 bg-primary-600 text-white' : 'border-gray-300 text-gray-700 hover:bg-gray-100 dark:border-gray-600 dark:text-gray-200 dark:hover:bg-gray-700' }}">
Záró
</button>
</td>
<td class="py-2 pr-4 font-mono text-xs whitespace-nowrap">{{ $commit['short'] }}</td>
<td class="py-2 pr-4 whitespace-nowrap text-gray-600 dark:text-gray-300">
{{ \Illuminate\Support\Carbon::parse($commit['date'])->format('Y.m.d H:i') }}
</td>
<td class="py-2 pr-4 whitespace-nowrap text-gray-600 dark:text-gray-300">{{ $commit['author'] }}</td>
<td class="py-2 pr-4 text-gray-800 dark:text-gray-100">{{ $commit['subject'] }}</td>
<td class="py-2 text-right text-gray-500 dark:text-gray-400">{{ $commit['file_count'] ?? '' }}</td>
</tr>
@endforeach
</tbody>
</table>
</div>
@endif
</div>
@if ($changeSet)
<div class="{{ $cardClass }}">
<h6 class="mb-4 text-lg font-semibold text-gray-900 dark:text-white">
Változott fájlok
<span class="ml-1 text-sm font-normal text-gray-500 dark:text-gray-400">
({{ $changeSet['counts']['total'] }} db)
</span>
</h6>
@foreach ($changeSet['warnings'] as $warning)
<div class="p-4 mb-3 border rounded-lg {{ $warningStyles[$warning['level']] ?? $warningStyles['info'] }}">
<p class="font-semibold">{{ $warning['title'] }}</p>
<p class="mt-1 text-sm">{{ $warning['body'] }}</p>
</div>
@endforeach
@if ($changeSet['files'] !== [])
<div class="overflow-x-auto">
<table class="w-full text-sm text-left">
<thead class="text-xs uppercase text-gray-600 border-b border-gray-200 dark:text-gray-300 dark:border-gray-700">
<tr>
<th class="py-2 pr-4">Állapot</th>
<th class="py-2 pr-4">Útvonal</th>
<th class="py-2">Művelet</th>
</tr>
</thead>
<tbody>
@foreach ($changeSet['files'] as $file)
@php
$style = $statusStyles[$file['status']] ?? ['label' => $file['status'], 'class' => 'bg-gray-100 text-gray-800 dark:bg-gray-700 dark:text-gray-200'];
@endphp
<tr wire:key="file-{{ $loop->index }}" class="border-b border-gray-100 dark:border-gray-700 {{ $file['excluded'] ? 'opacity-50' : '' }}">
<td class="py-2 pr-4 whitespace-nowrap">
<span class="px-2 py-0.5 text-xs font-semibold rounded {{ $style['class'] }}">
{{ $file['status'] }} · {{ $style['label'] }}
</span>
</td>
<td class="py-2 pr-4 font-mono text-xs break-all text-gray-800 dark:text-gray-100">
{{ $file['path'] }}
@if ($file['old_path'])
<span class="block text-gray-400"> {{ $file['old_path'] }}</span>
@endif
</td>
<td class="py-2 whitespace-nowrap text-gray-600 dark:text-gray-300">
@switch($file['action'])
@case('copy') másolandó @break
@case('delete') törlendő a célgépen @break
@default kihagyva (kizárási lista)
@endswitch
</td>
</tr>
@endforeach
</tbody>
</table>
</div>
@endif
</div>
@endif
</div>
</x-filament-panels::page>

View File

@ -173,6 +173,18 @@
*/ */
]; ];
// A deployment csomagoló kizárólag fejlesztői gépen érhető el (DeploymentGuard) -
// ugyanaz a canAccess() hívás, mint a modern speed-button-nav-bar.blade.php-ban.
if (\App\Filament\Pages\DeploymentPackage::canAccess()) {
$Modules['deploymentPackage'] = [
'name' => 'deploymentPackage',
'DisplayName' => 'Deployment',
'icon' => 'upload',
'link' => \App\Filament\Pages\DeploymentPackage::getUrl(),
'noAjax' => true,
'roles' => ['root', 'developer'],
];
}
$Modules=json_decode(json_encode($Modules)); $Modules=json_decode(json_encode($Modules));
?> ?>
{{-- {{--

View File

@ -0,0 +1,290 @@
<?php
use App\Filament\Pages\DeploymentPackage;
use App\Models\FeatureFlag;
use App\Models\Role;
use App\Models\User;
use App\Services\Deployment\ChangeSetAnalyzer;
use App\Services\Deployment\DeploymentGuard;
use App\Services\Deployment\GitRepository;
use App\Services\FeatureFlagRegistrar;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Config;
use Illuminate\Support\Facades\File;
use Livewire\Livewire;
use Symfony\Component\HttpKernel\Exception\HttpException;
use Symfony\Component\Process\Process;
use Tests\TestCase;
uses(TestCase::class, RefreshDatabase::class);
/**
* Fejlesztői gépet szimuláló környezet: az .env kapcsoló és a stage whitelist rendben.
*/
function deploymentAllowEnvironment(): void
{
Config::set('deployment.enabled', true);
Config::set('app.stage', 'local');
}
function deploymentDeveloper(bool $flagEnabled = true): User
{
$role = Role::firstOrCreate(['name' => 'developer'], ['display_name' => 'Developer']);
$user = User::factory()->create();
$user->addRole($role);
FeatureFlag::create([
'name' => 'DeploymentPackage',
'label' => 'Deployment csomagoló',
'enabled' => $flagEnabled,
'stages' => null,
'roles' => ['developer'],
]);
app(FeatureFlagRegistrar::class)->registerAll();
return $user;
}
/**
* @param array<int, string> $arguments
*/
function deploymentGit(string $path, array $arguments): string
{
$process = new Process(array_merge(['git'], $arguments), $path);
$process->mustRun();
return $process->getOutput();
}
/**
* Determinisztikus teszt-repó: átnevezés, törlés, ékezetes fájlnév és migráció is van benne.
*
* @return array{path:string, commits:array<int, string>}
*/
function deploymentTestRepository(): array
{
$path = sys_get_temp_dir().DIRECTORY_SEPARATOR.'deployment-package-test-'.uniqid();
File::makeDirectory($path.'/app', 0777, true);
File::makeDirectory($path.'/database/migrations', 0777, true);
deploymentGit($path, ['init']);
deploymentGit($path, ['config', 'user.email', 'teszt@example.com']);
deploymentGit($path, ['config', 'user.name', 'Teszt']);
deploymentGit($path, ['config', 'commit.gpgsign', 'false']);
File::put($path.'/app/Elso.php', "<?php\n// elso\n");
File::put($path.'/app/Regi.php', "<?php\n// regi\n");
File::put($path.'/app/Atnevezendo.php', "<?php\n// atnevezendo\n");
deploymentGit($path, ['add', '-A']);
deploymentGit($path, ['commit', '-m', 'első commit']);
deploymentGit($path, ['branch', '-M', 'main']);
File::put($path.'/app/Elso.php', "<?php\n// elso modositva\n");
File::put($path.'/app/Árlista.php', "<?php\n// ekezetes\n");
File::put($path.'/database/migrations/2026_01_01_000000_teszt.php', "<?php\n// migracio\n");
File::put($path.'/.env', "APP_KEY=titok\n");
deploymentGit($path, ['add', '-A', '-f']);
deploymentGit($path, ['commit', '-m', 'második commit']);
deploymentGit($path, ['mv', 'app/Atnevezendo.php', 'app/Atnevezett.php']);
deploymentGit($path, ['rm', 'app/Regi.php']);
deploymentGit($path, ['commit', '-m', 'harmadik commit']);
$log = trim(deploymentGit($path, ['log', '--reverse', '--pretty=format:%H']));
Config::set('deployment.repo_path', $path);
return ['path' => $path, 'commits' => explode("\n", $log)];
}
function deploymentCleanup(string $path): void
{
// A .git/objects tartalma Windowson csak olvasható, ezért törlés előtt fel kell oldani.
foreach (File::allFiles($path, true) as $file) {
@chmod($file->getPathname(), 0777);
}
try {
File::deleteDirectory($path);
} catch (Throwable) {
// A takarítás nem futtathat el tesztet - a temp mappa maradványa nem befolyásol semmit.
}
}
test('a kikapcsolt env kapcsoló mindenkitől elveszi a hozzáférést', function () {
deploymentAllowEnvironment();
Config::set('deployment.enabled', false);
$developer = deploymentDeveloper();
expect(app(DeploymentGuard::class)->isAllowed($developer))->toBeFalse()
->and(app(DeploymentGuard::class)->denialReason($developer))->toContain('DEPLOYMENT_PACKAGE_ENABLED');
});
test('a nem fejlesztői stage-en akkor sem érhető el, ha a kapcsoló be van kapcsolva', function () {
deploymentAllowEnvironment();
Config::set('app.stage', 'PROD');
$developer = deploymentDeveloper();
expect(app(DeploymentGuard::class)->isAllowed($developer))->toBeFalse()
->and(app(DeploymentGuard::class)->denialReason($developer))->toContain('fejlesztői környezetben');
});
test('a developer szerep nélküli felhasználó nem éri el', function () {
deploymentAllowEnvironment();
Role::create(['name' => 'admin', 'display_name' => 'Admin']);
$admin = User::factory()->create();
$admin->addRole(Role::where('name', 'admin')->first());
FeatureFlag::create([
'name' => 'DeploymentPackage',
'label' => 'Deployment csomagoló',
'enabled' => true,
'stages' => null,
'roles' => null,
]);
app(FeatureFlagRegistrar::class)->registerAll();
expect(app(DeploymentGuard::class)->isAllowed($admin))->toBeFalse();
});
test('a kikapcsolt feature flag elrejti a felületet a fejlesztő elől is', function () {
deploymentAllowEnvironment();
$developer = deploymentDeveloper(flagEnabled: false);
expect(app(DeploymentGuard::class)->isAllowed($developer))->toBeFalse()
->and(DeploymentPackage::canAccess())->toBeFalse();
});
test('minden feltétel teljesülése esetén engedélyezett', function () {
deploymentAllowEnvironment();
$developer = deploymentDeveloper();
expect(app(DeploymentGuard::class)->isAllowed($developer))->toBeTrue()
->and(app(DeploymentGuard::class)->denialReason($developer))->toBeNull();
});
test('a jogosulatlan felhasználó 403-at kap az oldal betöltésekor', function () {
deploymentAllowEnvironment();
$developer = deploymentDeveloper(flagEnabled: false);
$this->actingAs($developer);
expect(fn () => Livewire::test(DeploymentPackage::class))->toThrow(HttpException::class);
});
test('a commitlista a legfrissebbtől visszafelé, fájlszámmal együtt jön', function () {
$repository = deploymentTestRepository();
$commits = app(GitRepository::class)->commits('main', 10);
expect($commits)->toHaveCount(3)
->and($commits[0]['subject'])->toBe('harmadik commit')
->and($commits[2]['subject'])->toBe('első commit')
->and($commits[0]['hash'])->toHaveLength(40)
->and($commits[0]['file_count'])->toBe(2);
deploymentCleanup($repository['path']);
});
test('a diff felismeri az új, módosult, törölt és átnevezett fájlokat', function () {
$repository = deploymentTestRepository();
[$first, , $third] = $repository['commits'];
$files = collect(app(GitRepository::class)->changedFiles($first, $third))->keyBy('path');
expect($files->get('app/Elso.php')['status'])->toBe('M')
->and($files->get('app/Regi.php')['status'])->toBe('D')
->and($files->get('app/Atnevezett.php')['status'])->toBe('R')
->and($files->get('app/Atnevezett.php')['old_path'])->toBe('app/Atnevezendo.php')
// Ékezetes útvonal: core.quotePath=false nélkül "app/\303\201rlista.php" jönne vissza.
->and($files->has('app/Árlista.php'))->toBeTrue();
deploymentCleanup($repository['path']);
});
test('az elemzés kizárja a tiltott fájlokat és külön kezeli a törlendőket', function () {
$repository = deploymentTestRepository();
[$first, , $third] = $repository['commits'];
$changedFiles = app(GitRepository::class)->changedFiles($first, $third);
$changeSet = app(ChangeSetAnalyzer::class)->analyze($changedFiles);
expect($changeSet['copied'])->toContain('app/Árlista.php', 'app/Atnevezett.php', 'app/Elso.php')
->and($changeSet['copied'])->not->toContain('.env')
->and($changeSet['counts']['excluded'])->toBe(1)
// A törölt fájl mellett az átnevezés régi útvonala is törlendő a célgépen.
->and($changeSet['deleted'])->toContain('app/Regi.php', 'app/Atnevezendo.php');
$titles = collect($changeSet['warnings'])->pluck('title')->implode(' | ');
expect($titles)->toContain('Migráció')
->and($titles)->toContain('Törlendő fájl');
deploymentCleanup($repository['path']);
});
test('az érvénytelen commit azonosítót nem adja tovább a gitnek', function () {
$repository = deploymentTestRepository();
$git = app(GitRepository::class);
expect($git->resolveCommit('../../etc/passwd'))->toBeNull()
->and($git->resolveCommit('main; rm -rf /'))->toBeNull()
->and($git->resolveCommit('HEAD'))->toBeNull()
->and($git->resolveCommit($repository['commits'][0]))->toBe($repository['commits'][0]);
deploymentCleanup($repository['path']);
});
test('a fordított tartomány felismerhető', function () {
$repository = deploymentTestRepository();
[$first, , $third] = $repository['commits'];
$git = app(GitRepository::class);
expect($git->isAncestor($first, $third))->toBeTrue()
->and($git->isAncestor($third, $first))->toBeFalse();
deploymentCleanup($repository['path']);
});
test('az oldal kilistázza a commitokat és a kijelölt tartomány fájljait', function () {
deploymentAllowEnvironment();
$repository = deploymentTestRepository();
[$first, , $third] = $repository['commits'];
$this->actingAs(deploymentDeveloper());
Livewire::test(DeploymentPackage::class)
->assertSuccessful()
->assertSee('harmadik commit')
->call('selectFrom', $first)
->call('selectTo', $third)
->assertSet('fromCommit', $first)
->assertSet('toCommit', $third)
->assertSee('app/Atnevezett.php')
->assertSee('app/Regi.php');
deploymentCleanup($repository['path']);
});
test('az oldal nem fogad el kamu commit azonosítót', function () {
deploymentAllowEnvironment();
$repository = deploymentTestRepository();
$this->actingAs(deploymentDeveloper());
Livewire::test(DeploymentPackage::class)
->call('selectFrom', '../../etc/passwd')
->assertSet('fromCommit', null);
deploymentCleanup($repository['path']);
});