diff --git a/.ai/lessons.md b/.ai/lessons.md index c15a0558ec..dccfba874f 100644 --- a/.ai/lessons.md +++ b/.ai/lessons.md @@ -36,7 +36,7 @@ - In tests, turning on `is_metrics_enabled` (and other Sentinel settings) with `save()`/`update()` runs the settings hook, which starts Sentinel over a real SSH connection on the sync queue. Use `saveQuietly()` or `Queue::fake()`. - Call `visit()` directly in each `tests/v4/Browser` test body; Pest does not mark a test that only uses helper-wrapped `visit()` as a browser test, so it fails with `sendText() on null`. - The root team (id 0) has `personal_team = true`, so `personal_team` does not identify a user's own team. To clean up test users, detach them from shared teams and then delete the user; never delete a team selected by a filter. A Team delete revokes the team's API tokens and deletes its private keys before any guard can stop it. -- SQLite returns `0`/`1` for boolean columns without a `boolean` cast, while Postgres returns real booleans. A strict `=== false` check on such a column never matches in tests, so tests can pass on behavior that Postgres blocks. Add the cast or compare loosely. +- SQLite returns `0`/`1` for boolean columns without a `boolean` cast, while Postgres returns real booleans. A strict `=== false` check on such a column never matches in tests, so tests can pass on behavior that Postgres blocks. Add the cast or compare loosely. `Rule::exists()->where('column', false)` sends `false` as `""`, which Postgres rejects for a boolean column; use a closure (`->where(fn ($query) => $query->where('column', false))`). - SQLite does not enforce `varchar(255)` lengths, while Postgres rejects a longer value (`SQLSTATE[22001]`). Tests cannot show a too-long name, so assert the length or the exact value of generated strings. - `docker compose up` gives a recreated container the old mount at each path that the image declares with `VOLUME`, also a named volume or bind mount that the Compose file no longer has. Remove the old container first when a mount must go (`composeContainersWithRemovedVolumes()`); do not use `--renew-anon-volumes`, because it deletes the data of anonymous volumes. @@ -47,6 +47,9 @@ - To prove BuildKit cache behavior, use a new Dockerfile with a unique step for each experiment and repeat each build. Old cache records can make a changed input look cached. BuildKit keys `RUN` steps on the `--add-host` names and their order, but not on the IPs. +## Add dependencies through the trust gate +- `composer require` stops until `vendor/bin/vet` records the new packages in `vet.json`. Read the package source first, then record it with `vendor/bin/vet` in a terminal (it needs a TTY). Prefer the existing dependencies (Guzzle, curl, Flysystem) over a package with a large dependency tree. + ## Verify the complete user flow - Do not use a passing unit test, a successful build, or a healthy process as proof for a reported UI failure. - Verify the exact live flow, persisted state, relevant logs, and queue state when they affect the result. @@ -169,3 +172,4 @@ ## Measure database reads end to end - On PostgreSQL 15, `EXPLAIN (ANALYZE, BUFFERS)` does not detoast or send the selected columns, so a wide `jsonb`/`json`/`text` column does not show in it. Time the full fetch (`psql \timing` with `-o /dev/null`, or PDO `fetchAll`) as well before you blame or rule out an index. +- Do not put a required write (audit rows, state that matches a committed change) or required cleanup in `defer()` or `dispatch()->afterResponse()`. Both run in the terminate phase, which PHP-FPM skips when the client or proxy closes the connection (`ignore_user_abort` is off) or the script hits a fatal error. Write it in `DB::afterCommit()`, or queue the job normally, instead. `afterResponse()` also runs a queued job on the `sync` connection in the web worker. diff --git a/CHANGELOG.md b/CHANGELOG.md index af891acf8b..00f78fc3e7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -60,6 +60,19 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), - Retry GitHub API reads, such as the repository lookup before each deployment, up to three times when GitHub answers with a 5xx error or the connection fails. Before, one `502` or one timeout from GitHub failed the deployment. Writes, such as pull request comments, are not retried ([#9785](https://github.com/coollabsio/coolify/issues/9785)) - Keep an application that uses a server only as an additional destination when you delete that server with **Delete all resources** or with `DELETE /api/v1/servers/{uuid}?force=true`. Before, Coolify deleted the application on all its servers, also after **Promote** made another server its main server. Coolify now removes the application only from the deleted server and stops its containers there. A server that has only such applications can be deleted without **Delete all resources**. The delete confirmation lists the resources that it deletes and the applications that keep running on another server - Fix the sign-up and login of the Rybbit service template. The proxy sent all requests to the client container, so the `/api` requests of the browser got a 404 error. The template now routes `/api` on the same domain to the backend on port 3001, and keeps the `/api` prefix. For an existing Rybbit service, add `SERVICE_URL_RYBBIT_3001=/api` to the `rybbit_backend` service, move `rybbit_backend` above `rybbit` in the Docker Compose file, set **Path prefixes** to **Keep prefixes** in the advanced settings of the backend, and redeploy the service ([#9055](https://github.com/coollabsio/coolify/issues/9055)) +- Add SMTP templates for common email providers, such as PostShiba, Amazon SES, Mailgun, SendGrid, Postmark, and Brevo, which fill in the host, port, and encryption +- Save more than one named SMTP server on the transactional email and email notification pages, and switch between them +- Give each SMTP server and Resend its own sender name and address, because each provider sends from its own verified domain +- Disable a scheduled task automatically when it fails at least 5 times in a row and has had no successful run for 3 days. Coolify sends a notification on the scheduled task failure channels and shows the reason on the task page until you enable the task again +- Connect Forgejo instances, including Codeberg and self-hosted Forgejo, with the Gitea / Forgejo App source. A new Provider list on the create form fills in the Gitea.com or Codeberg URLs, and webhooks also accept the `X-Forgejo-*` headers + +### Changed + +- Select the email provider from one list on the transactional email and email notification pages, which then show only the settings of that provider + +### Removed + +- Remove the "Copy from instance settings" button from the email notification page. Select "Use system-wide settings" to send team email with the instance email settings ## [v4.6.0] - 2026-11-02 diff --git a/DESIGN.md b/DESIGN.md index 2f22c906c4..73d5cf74db 100644 --- a/DESIGN.md +++ b/DESIGN.md @@ -934,6 +934,15 @@ The shared workspace grid is: Instance Settings uses `x-settings.layout` with the same full-width workspace. +The Security item of the resource sidebars shows a status dot at its end +(``, set `'security'` on the item to +the stored status): a red `bg-error` dot for update_now and a yellow +`bg-warning` dot for update_available, each with an `aria-label` +("Security: update now", "Security: security update") that is also the tooltip. +Other statuses show nothing, and the item never shows a count. Status that needs +live updates keeps its own component (the application Servers item uses the +`badge` server status badge). + **Page titles (global):** `x-dashboard.navbar` H1s (`titleOnDesktop="false"`, the default) hide at **lg+** only when the page renders a fixed tab strip or actions; otherwise they stay visible. Collection indexes (Servers, Projects, …) @@ -1005,6 +1014,13 @@ Anatomy: The title row has `4px 8px 10px` padding inside the 6px outer padding, so header actions sit 10px from the top and 14px from the right edge. +A card that most users do not need open can be collapsible: pass +`collapsible` (and `:open="true"` to start open). The title becomes a button +with a chevron (`aria-expanded`, `aria-controls` the well), the well is hidden +until it is opened, and the closed title row has a 4px bottom padding. Header +actions stay visible while it is closed. The Security page uses it for "All +findings ({n})", "Packages and SBOM", and "Settings". + The card keeps `overflow: visible` so listbox panels and tooltips can escape it. Do not add `overflow: hidden` to the card or the well. @@ -1179,6 +1195,30 @@ history filter in Do not represent the empty state as a selectable `All` option. The footer reset action is the single way to return the multi-select to its unfiltered state. +#### Inherited listbox option + +A setting that inherits through levels (for example instance → team → server +→ app for image scan rules) uses `x-forms.inherited-listbox`. It wraps +`x-forms.listbox` and prepends one option with the value `''`: + +```blade + +``` + +- the label is `Use {level} default ({value})`: `{level}` is the level the + inherited value comes from (`Coolify` for the instance level, then `team`, + `server`), and `{value}` is that value's option label; +- choosing it stores `null`, so the field follows later changes of the level + above. Do not copy the inherited value into the row; +- the top level has concrete values and no inherit option (pass no + `inheritLabel`), and the server rejects an empty value for it; +- it saves instantly (`onChange`), like the other instant-save listboxes; +- a related choice that is not inheritance, such as "Detect automatically", + uses a plain listbox with its own first option instead. + +The reference is `resources/views/livewire/image-scan/partials/scan-rules.blade.php`. + ### Standard table controls Dense tables use the shared `x-table.*` components so search, filters, sorting, @@ -1198,6 +1238,32 @@ queries, permissions, and empty states. Backend-filtered or paginated tables must use `x-table.loading`; frontend-only Alpine tables reuse the same toolbar and control anatomy but do not show an artificial loading state. +### Fix-state switch + +The "All findings ({n})" card of the Security page splits findings into "Fixed +by an update" (a fixed version exists, or an action of the main card fixes it) +and "No fix yet". This is a view switch, not a filter, so it is not in the +`x-table.filter` menu and "Reset filters" keeps its meaning: + +- a segmented control in the table toolbar, after the search: a 4px-padded + neutral track (`bg-neutral-100`, dark `white/4%`) with 28px, 6px-radius + buttons; the current one is the raised white pill (dark `white/9%`) with a + hairline ring; +- `role="group"` with an `aria-label`, and `aria-pressed` on each button; +- each label has its count in parentheses: `Fixed by an update (5)`. The counts + follow the search and the filters, so a switch never hides results without + saying how many; +- "Fixed by an update" is the default. When it is empty, the empty state says + "No finding has a fixed version." and links to "No fix yet ({n})"; +- the same segmented anatomy is used for the "By package / By CVE" grouping + toggle on the right of the toolbar; +- ignored findings are not a third state. The table footer has an "{n} ignored" + link that opens the ignored list; +- a finding of a Dockerfile build that the base image has too says "Comes from + {base}." in its details. + +The reference is `resources/views/livewire/image-scan/vulnerabilities.blade.php`. + ### Buttons - neutral actions use the shared `.button`; @@ -1272,6 +1338,270 @@ Relevant classes: Create a page-specific grid class when columns differ. Add responsive rules that hide secondary columns before allowing horizontal overflow. +### Severity badge, security marker, gate status, and the Security main card + +Image scan findings use shared pieces in +`resources/views/components/image-scan/`. They keep the status badge rule: +the surface stays neutral and only a small mark carries the color, and the +severity word is always in the text, so color is never the only signal. + +Severity tokens live in `app.css`, with a light value in `@theme` and a dark +value in `.dark`. They are OKLCH only and never follow the accent or +`--color-warning`, because the custom theme recolors both. + +| Severity | Dot color | Why | +|---|---|---| +| critical | `--color-error` (`bg-error`) | the existing error red | +| high | `--color-severity-high` (orange) | between error red and warning yellow | +| medium | `--color-severity-medium` (sky blue) | not yellow like warnings, not purple like the accent | +| low, unknown | neutral | no urgency | + +- Every severity dot clears 3:1 (non-text contrast) on the badge background in + both themes, and medium is more than 10 ΔE (OKLab ×100) from the accent and + the warning color. `tests/Unit/ImageScan/SeverityTokenContrastTest.php` + enforces both. Change a token and run it. +- `` renders + `x-status-badge` with the types `severity-critical`, `severity-high`, + `severity-medium`, or `severity-low` (unknown is neutral). The label is + `2 critical`, or `Critical` (sentence case) when `count` is null. +- `` + is the security marker for lists: a `shield-alert` icon with visible text, + never a tooltip-only count, so it works on touch screens. + - Only two statuses get one: update_now ("Update now", the icon in + `text-error`) and no_fix ("No fix yet", a neutral icon). The color is on + the icon only; the text keeps the normal foreground. Every other status + renders nothing. + - `tooltip` (`data-tooltip`) is the sentence of the primary action, when + update_now has one. `label` (`aria-label`) names the status and that + action: "Security: update now. Update to 15.15." The marker sets + `data-tooltip-wrap`, so a long action sentence wraps at `max-w-xs` + instead of running off the screen. Short labels keep the default + one-line tooltip; add `data-tooltip-wrap` only to sentence tooltips. + - It sits right after the resource name: the environment list (table and + cards), the server Resources tab, and the canvas cards (in the chip row, + before the extra servers chip; `Card.svelte` draws the same anatomy from + the node's `security` value). + - Lists rendered by Alpine pass an expression instead of values: + ``. Add + `relative` when the row has a full-row link overlay, so the tooltip still + opens. + - Compute the markers of a whole list at once with + `VulnerabilityMarkers::for($resources)`. It reads the stored statuses + (`SecurityStatus::storedMany()`) with a fixed number of queries, never one + per row, and never reads findings. A resource that is not scanned now + (instance does not allow scanning, its server has scanning off, or an app + turned its scan off; one rule in `ImageScanPolicyResolver::offState()`) is + not_checked: no marker, no sidebar dot, and no dashboard count. +- The team Security page (Monitoring > Security, + `livewire/image-scan/fleet-vulnerabilities.blade.php`) is a top-level + destination: title and summary, one dense table with every resource the + user can see. + - Columns: Resource (with project / environment under the name), Status, + Action, Server, Checked. + - Status: the badge of the main card ("Update now" error, "Security update" + warning, "No fix yet" and "Check needed" neutral), or muted text "No action + needed" (ok) and "Not checked". A resource with several images that needs + an update adds "{k} of {n} images" under the badge (11px muted, + `TeamVulnerabilities::imagesLine()`). One row per resource; images are never + nested rows. + - Action: the sentence of the primary action (update_now and + update_available), as a link to the resource's Security card + (`#security-section`) in the text link style (`underline + decoration-neutral-400/40 underline-offset-4`); muted "Check again: + {reason}." as the same link for check_needed; muted, unlinked "No fix + released yet. Coolify checks daily." for no_fix; muted, unlinked "why not + checked" text for not_checked. No buttons and no bulk actions. + - Sort: update_now, update_available, no_fix, check_needed, ok, + not_checked, then the resource name. Filters: Status (with a count per + status, "Update now (3)", and in the URL as `statuses[]`), Server, and + Project in one `x-table.filter`. 25 rows per page by default. + - A search by CVE, GHSA, or package lists the resources with a matching + finding and switches the Status and Action columns to the match and its + state ("Fix available upstream", "No fix yet", "Ignored", the words of + All findings). A small status badge stays next to the resource name. + Only the search decodes findings. + - Resources that were not scanned are hidden by default (no status filter), + so they do not bury the ones that need an action, but never silently: a + muted line under the table says "{n} resources on {m} servers were not + scanned, so Coolify cannot tell." with the link "Show them" / "Hide them" + (`showNotChecked`, in the URL). The Status filter "Not checked" shows them + too. + - A scan event reloads the page at most once per 10 seconds + (`FleetVulnerabilities::RELOAD_INTERVAL`); a server scan sends one event + per image. +- Fleet callouts: + - The dashboard shows one callout, only for update_now: "{n} {resource + needs/resources need} a security update now." + Review (danger), which + opens the Security page filtered to update_now. After a dismiss it shows + again only when a resource that was not dismissed needs an update now: + `resources/js/security-callout.js` keeps the dismissed resource UUIDs in + `localStorage` and clears them when no resource needs an update now. + Nothing else about security goes on the dashboard. + - The Security page shows admins "{n} servers do not scan images." Its + `localStorage` key holds the number, so a new number shows it again. + - Callout keys are per team (`….dismissed.{teamId}`): a dismiss in one team + never hides the callout of another. +- `` is the + deploy gate state: a `x-status-badge` button ("Blocked" error, or "Not + scanned" warning when the scan could not run) that opens a popover with the + same anatomy as `x-configuration-warning` (icon tile, headline, links). The + popover is `position: fixed` and placed from the trigger, so it escapes the + `overflow: hidden` log viewer. It replaces the status dot in the deployment + log toolbar when blocked (no card above the log). The application heading + teleports it into `#configuration-warning-hud-slot` from `xl` and repeats it + in the in-flow heading below `xl`, for the latest non-preview deployment + only. A blocked popover shows the sentence of the primary action for the + blocked image (`:action`, from `SecurityCard::gateAction()`) and an "Open + Security" link; it never runs an update itself. "Deploy anyway" shows only + where the page can run it and the user has Write access; otherwise "Ask + someone with write access to deploy anyway." +- The Security main card (`livewire/image-scan/vulnerabilities.blade.php`, data + from `SecurityCard::for()`) is the first layer card of a resource's Security + page, for people who are not security experts. It answers "Is this OK, and + what do I click?" first: status, sentence, and one action, then the why-list + (spec 15.11). It is one component for every resource: a list of the images + the status counts. No summary tiles, no severity counts, no separate + problems table, and no row menus: a count that the status does not use + (upstream package fixes, CPE-only matches, findings that are not relevant) + contradicts the sentence. + - Card header: title "Security" with the status badge of the resource in the + badges slot ("Update now" error, "Security update" warning, "No fix yet" + and "Check needed" neutral, none for ok and not checked), and "Checked + {time}" (absolute time in the tooltip) and Scan now in the header actions + (not for check_needed, where Scan now is the primary action in the body). + - Vocabulary: the plain layer (the card) says "problems"; the expert layer + (All findings, Packages and SBOM, the row footer, ignores) says + "findings". Never "issues" or "vulnerabilities" in the UI. + - Not scanned yet: apps say "Not scanned yet. The next deploy scans the + image."; databases and services say "Not scanned yet." with a highlighted + Scan now (only for users who may update the resource). + - One image (a database, a Docker image app, a Dockerfile app): the row is + open and has no list frame, no row header, no chevron, and no second status + badge; the card badge is the status. The image ref is shown in the + sentence only when the sentence starts with it; an own-built app ("This app + has…") shows the ref only in the footer. + - Rows: every image the status counts. That includes a scanned service + application or database without a result and every image of a Compose + app without a result in its current deployment (keyed + `compose-{md5(service)}`). These rows show "Not checked" and "{ref} has + not been checked yet.", and Scan now stays in the card header. + - Several images (a service, a Compose app): a 15px medium line "{k} of {n} + images need an update" only when k > 0 (k = rows with update_now or + update_available). For k = 0 there is no header line: the card badge + already says the status, and "All images are up to date" is never used. + Then one bordered list (`aria-label="Images"`, rows split by dividers). + Rows are worst first (update_now, update_available, no_fix, check_needed, + ok, not_checked, then the name). The first row with a problem is open; the + others, and every "No action needed" row, are closed. + - Row header (several images): a real ` @@ -71,7 +71,7 @@ 'name' => $source->name, 'provider' => match ($source->getMorphClass()) { 'App\\Models\\GithubApp' => 'GitHub', - 'App\\Models\\GiteaApp' => 'Gitea', + 'App\\Models\\GiteaApp' => 'Gitea / Forgejo', default => 'GitLab', }, 'organization' => $isGitlab ? $source->group_name : $source->organization, @@ -143,7 +143,7 @@ @elseif ($source->getMorphClass() === 'App\Models\GiteaApp') -
diff --git a/routes/api.php b/routes/api.php index 2f883c0cd3..2de9a3eb4c 100644 --- a/routes/api.php +++ b/routes/api.php @@ -29,6 +29,7 @@ use App\Http\Controllers\Api\SentinelController; use App\Http\Controllers\Api\ServerCloudflareTunnelController; use App\Http\Controllers\Api\ServerDockerCleanupController; use App\Http\Controllers\Api\ServerDockerRegistriesController; +use App\Http\Controllers\Api\ServerImageScanningController; use App\Http\Controllers\Api\ServerLogDrainsController; use App\Http\Controllers\Api\ServerProxyController; use App\Http\Controllers\Api\ServersController; @@ -41,6 +42,7 @@ use App\Http\Controllers\Api\SharedEnvironmentVariablesController; use App\Http\Controllers\Api\TagsController; use App\Http\Controllers\Api\TeamController; use App\Http\Controllers\Api\VolumeBackupsController; +use App\Http\Controllers\Api\VulnerabilitiesController; use App\Http\Controllers\Api\VultrController; use App\Http\Middleware\ApiAllowed; use Illuminate\Support\Facades\Route; @@ -184,6 +186,9 @@ Route::group([ Route::get('/servers/{uuid}/sentinel', [ServerSentinelController::class, 'show'])->middleware(['api.ability:read']); Route::patch('/servers/{uuid}/sentinel', [ServerSentinelController::class, 'update'])->middleware(['api.ability:write', 'api.ability:deploy']); + Route::get('/servers/{uuid}/image-scanning', [ServerImageScanningController::class, 'show'])->middleware(['api.ability:read']); + Route::patch('/servers/{uuid}/image-scanning', [ServerImageScanningController::class, 'update'])->middleware(['api.ability:write']); + Route::get('/servers/{uuid}/cloudflare-tunnel', [ServerCloudflareTunnelController::class, 'show'])->middleware(['api.ability:read']); Route::patch('/servers/{uuid}/cloudflare-tunnel', [ServerCloudflareTunnelController::class, 'update'])->middleware(['api.ability:write']); Route::post('/servers/{uuid}/cloudflare-tunnel/enable', [ServerCloudflareTunnelController::class, 'enable'])->middleware(['api.ability:write']); @@ -199,6 +204,7 @@ Route::group([ Route::get('/servers/{uuid}/validate', [OtherController::class, 'post_required'])->middleware(['api.ability:write']); Route::post('/servers/{uuid}/validate', [ServersController::class, 'validate_server'])->middleware(['api.ability:write']); + Route::post('/servers/{uuid}/host-key/reset', [ServersController::class, 'reset_host_key'])->middleware(['api.ability:write']); Route::get('/servers/{uuid}/registries', [ServerDockerRegistriesController::class, 'index'])->middleware(['api.ability:read:sensitive']); Route::post('/servers/{uuid}/registries', [ServerDockerRegistriesController::class, 'login'])->middleware(['api.ability:write', 'api.ability:deploy']); @@ -247,6 +253,7 @@ Route::group([ Route::post('/servers/hostinger', [HostingerController::class, 'createServer'])->middleware(['api.ability:write']); Route::get('/resources', [ResourcesController::class, 'resources'])->middleware(['api.ability:read']); + Route::get('/vulnerabilities', [VulnerabilitiesController::class, 'index'])->middleware(['api.ability:read']); Route::get('/tags', [TagsController::class, 'tags'])->middleware(['api.ability:read']); Route::post('/tags', [TagsController::class, 'create'])->middleware(['api.ability:write']); @@ -262,6 +269,7 @@ Route::group([ Route::post('/applications/dockerimage', [ApplicationsController::class, 'create_dockerimage_application'])->middleware(['api.ability:write']); Route::get('/applications/{uuid}', [ApplicationsController::class, 'application_by_uuid'])->middleware(['api.ability:read']); + Route::get('/applications/{uuid}/vulnerabilities', [VulnerabilitiesController::class, 'application'])->middleware(['api.ability:read']); Route::patch('/applications/{uuid}', [ApplicationsController::class, 'update_by_uuid'])->middleware(['api.ability:write']); Route::delete('/applications/{uuid}', [ApplicationsController::class, 'delete_by_uuid'])->middleware(['api.ability:delete']); @@ -351,6 +359,7 @@ Route::group([ Route::post('/databases/sqlite', [DatabasesController::class, 'create_database_sqlite'])->middleware(['api.ability:write']); Route::get('/databases/{uuid}', [DatabasesController::class, 'database_by_uuid'])->middleware(['api.ability:read']); + Route::get('/databases/{uuid}/vulnerabilities', [VulnerabilitiesController::class, 'database'])->middleware(['api.ability:read']); Route::post('/databases/{uuid}/imports/uploads', [DatabasesController::class, 'upload_import'])->middleware(['api.ability:write'])->name('api.databases.imports.upload'); Route::post('/databases/{uuid}/imports', [DatabasesController::class, 'create_import'])->middleware(['api.ability:write'])->name('api.databases.imports.store'); Route::get('/databases/{uuid}/imports/{activity_id}', [DatabasesController::class, 'show_import'])->middleware(['api.ability:read'])->whereNumber('activity_id')->name('api.databases.imports.show'); @@ -402,6 +411,7 @@ Route::group([ Route::post('/services', [ServicesController::class, 'create_service'])->middleware(['api.ability:write']); Route::get('/services/{uuid}', [ServicesController::class, 'service_by_uuid'])->middleware(['api.ability:read']); + Route::get('/services/{uuid}/vulnerabilities', [VulnerabilitiesController::class, 'service'])->middleware(['api.ability:read']); Route::patch('/services/{uuid}', [ServicesController::class, 'update_by_uuid'])->middleware(['api.ability:write']); Route::delete('/services/{uuid}', [ServicesController::class, 'delete_by_uuid'])->middleware(['api.ability:delete']); diff --git a/routes/web.php b/routes/web.php index 4608988327..07b03bacf9 100644 --- a/routes/web.php +++ b/routes/web.php @@ -16,12 +16,18 @@ use App\Http\Controllers\Api\SharedVariableCanvasController; use App\Http\Controllers\Api\TagCanvasController; use App\Http\Controllers\Controller; use App\Http\Controllers\FileBrowserDownloadController; +use App\Http\Controllers\ImageScanSbomController; use App\Http\Controllers\OauthController; use App\Http\Controllers\ProfileAvatarController; use App\Http\Controllers\ProjectIconController; use App\Http\Controllers\TeamAvatarController; use App\Http\Controllers\UploadController; +use App\Http\Middleware\CheckForcePasswordReset; +use App\Http\Middleware\DecideWhatToDoWithUser; +use App\Http\Middleware\EnsureTeamWideAccess; +use App\Http\Middleware\RequireTeamTwoFactor; use App\Http\Middleware\UseSessionTeamApiToken; +use App\Http\Middleware\VerifyCsrfToken; use App\Livewire\Admin\Index as AdminIndex; use App\Livewire\Analytics; use App\Livewire\Boarding\Index as BoardingIndex; @@ -34,6 +40,10 @@ use App\Livewire\Destination\Show as DestinationShow; use App\Livewire\Dev\LivewireRequestFailurePreview; use App\Livewire\Fleet\Metrics as FleetMetrics; use App\Livewire\ForcePasswordReset; +use App\Livewire\ImageScan\FleetVulnerabilities; +use App\Livewire\ImageScan\InstanceImageScanning; +use App\Livewire\ImageScan\ServerImageScanning; +use App\Livewire\ImageScan\TeamImageScanning; use App\Livewire\Notifications\Discord as NotificationDiscord; use App\Livewire\Notifications\Email as NotificationEmail; use App\Livewire\Notifications\Pushover as NotificationPushover; @@ -75,7 +85,6 @@ use App\Livewire\Security\CloudProviderToken\Show as SecurityCloudProviderTokenS use App\Livewire\Security\CloudTokens; use App\Livewire\Security\IntegrationTokens; use App\Livewire\Security\PrivateKey\Index as SecurityPrivateKeyIndex; -use App\Livewire\Security\PrivateKey\Show as SecurityPrivateKeyShow; use App\Livewire\SelectTeam; use App\Livewire\Server\Advanced as ServerAdvanced; use App\Livewire\Server\Analytics\Show as ServerAnalytics; @@ -100,8 +109,7 @@ use App\Livewire\Server\Proxy\Logs as ProxyLogs; use App\Livewire\Server\Proxy\Show as ProxyShow; use App\Livewire\Server\Registries as ServerRegistries; use App\Livewire\Server\Resources as ResourcesShow; -use App\Livewire\Server\Security\Patches; -use App\Livewire\Server\Security\TerminalAccess; +use App\Livewire\Server\Security\Index as ServerSecurityIndex; use App\Livewire\Server\Sentinel\Logs as SentinelLogs; use App\Livewire\Server\Sentinel\Show as SentinelShow; use App\Livewire\Server\Show as ServerShow; @@ -207,6 +215,8 @@ Route::middleware(['auth', 'verified'])->group(function () { })->name('two-factor.required.switch-team'); Route::get('/', Dashboard::class)->name('dashboard'); + // Members limited to some projects see the resources they have access to. + Route::get('/vulnerabilities', FleetVulnerabilities::class)->name('vulnerabilities'); Route::middleware('access.team-wide')->group(function () { Route::get('/dashboard/canvas', DashboardCanvas::class)->name('dashboard.canvas'); Route::get('/analytics', Analytics::class)->name('analytics'); @@ -281,6 +291,7 @@ Route::middleware(['auth', 'verified'])->group(function () { Route::get('/settings', SettingsIndex::class)->name('settings.index'); Route::get('/settings/advanced', SettingsAdvanced::class)->name('settings.advanced'); Route::get('/settings/updates', SettingsUpdates::class)->name('settings.updates'); + Route::get('/settings/image-scanning', InstanceImageScanning::class)->name('settings.image-scanning'); Route::get('/settings/backup', SettingsBackup::class)->name('settings.backup'); Route::get('/settings/email', SettingsEmail::class)->name('settings.email'); @@ -336,6 +347,7 @@ Route::middleware(['auth', 'verified'])->group(function () { Route::get('/members', TeamMemberIndex::class)->name('team.member.index')->middleware('access.team-wide'); Route::get('/members/{user_id}/avatar', ProfileAvatarController::class)->whereNumber('user_id')->name('team.member.avatar'); Route::get('/audit-log', TeamAuditLog::class)->name('team.audit-log')->middleware('access.team-wide'); + Route::get('/image-scanning', TeamImageScanning::class)->name('team.image-scanning')->middleware('access.team-wide'); Route::get('/admin', TeamAdminView::class)->name('team.admin-view')->middleware('access.team-wide'); Route::get('/danger', TeamDangerZone::class)->name('team.danger-zone')->middleware('access.team-wide'); }); @@ -377,11 +389,17 @@ Route::middleware(['auth', 'verified'])->group(function () { Route::post('/terminal/session', function (Request $request, TerminalSessionService $terminalSessionService) { $request->validate(['token' => ['required', 'string', 'size:64']]); - return response()->json([ - 'command' => $terminalSessionService->redeem($request->user(), $request->string('token')->toString()), - ]); + return response()->json($terminalSessionService->redeem($request->user(), $request->string('token')->toString())); })->name('terminal.session')->middleware('can.access.terminal'); + // No page gates: the check uses the team of the server, and the user can switch teams in another tab. + Route::post('/terminal/session/check', function (Request $request, TerminalSessionService $terminalSessionService) { + $request->validate(['session_id' => ['required', 'string', 'size:64']]); + $terminalSessionService->check($request->user(), $request->string('session_id')->toString()); + + return response()->json(['active' => true]); + })->name('terminal.session.check')->withoutMiddleware([CheckForcePasswordReset::class, DecideWhatToDoWithUser::class, RequireTeamTwoFactor::class, EnsureTeamWideAccess::class]); + Route::prefix('invitations')->group(function () { Route::get('/{uuid}', [Controller::class, 'showInvitation'])->name('team.invitation.show'); Route::post('/{uuid}', [Controller::class, 'acceptInvitation'])->name('team.invitation.accept'); @@ -428,6 +446,8 @@ Route::middleware(['auth', 'verified'])->group(function () { Route::get('/resource-operations', ApplicationConfiguration::class)->name('project.application.resource-operations'); Route::get('/metrics', ApplicationConfiguration::class)->name('project.application.metrics'); Route::get('/analytics', ApplicationConfiguration::class)->name('project.application.analytics'); + Route::get('/vulnerabilities', ApplicationConfiguration::class)->name('project.application.vulnerabilities'); + Route::get('/vulnerabilities/sbom/{scan_uuid}/{format}', ImageScanSbomController::class)->name('project.application.vulnerabilities.sbom'); Route::get('/tags', ApplicationConfiguration::class)->name('project.application.tags'); Route::get('/danger', ApplicationConfiguration::class)->name('project.application.danger'); @@ -451,6 +471,8 @@ Route::middleware(['auth', 'verified'])->group(function () { Route::get('/resource-limits', DatabaseConfiguration::class)->name('project.database.resource-limits'); Route::get('/resource-operations', DatabaseConfiguration::class)->name('project.database.resource-operations'); Route::get('/metrics', DatabaseConfiguration::class)->name('project.database.metrics'); + Route::get('/vulnerabilities', DatabaseConfiguration::class)->name('project.database.vulnerabilities'); + Route::get('/vulnerabilities/sbom/{scan_uuid}/{format}', ImageScanSbomController::class)->name('project.database.vulnerabilities.sbom'); Route::get('/tags', DatabaseConfiguration::class)->name('project.database.tags'); Route::get('/danger', DatabaseConfiguration::class)->name('project.database.danger'); @@ -470,6 +492,8 @@ Route::middleware(['auth', 'verified'])->group(function () { Route::get('/domains', ServiceConfiguration::class)->name('project.service.domains'); Route::get('/logs', Logs::class)->name('project.service.logs'); Route::get('/analytics', ServiceConfiguration::class)->name('project.service.analytics'); + Route::get('/vulnerabilities', ServiceConfiguration::class)->name('project.service.vulnerabilities'); + Route::get('/vulnerabilities/sbom/{scan_uuid}/{format}', ImageScanSbomController::class)->name('project.service.vulnerabilities.sbom'); Route::get('/environment-variables', ServiceConfiguration::class)->name('project.service.environment-variables'); Route::get('/storages', ServiceConfiguration::class)->name('project.service.storages'); // Import opens in a modal on the backups page; keep old links working. @@ -537,9 +561,13 @@ Route::middleware(['auth', 'verified'])->group(function () { Route::get('/images', DockerImages::class)->name('server.docker-images'); Route::get('/github-runners', GithubRunners::class)->name('server.github-runners'); Route::get('/registries', ServerRegistries::class)->name('server.registries')->middleware('can.update.resource'); - Route::get('/security', fn () => redirect(route('dashboard')))->name('server.security')->middleware('can.update.resource'); - Route::get('/security/patches', Patches::class)->name('server.security.patches')->middleware('can.update.resource'); - Route::get('/security/terminal-access', TerminalAccess::class)->name('server.security.terminal-access')->middleware('can.update.resource'); + Route::get('/security', ServerSecurityIndex::class)->name('server.security')->middleware('can.update.resource'); + Route::get('/security/image-scanning', ServerImageScanning::class)->name('server.security.image-scanning')->middleware('can.update.resource'); + // Old sub pages. Links in sent notifications still use them. + foreach (['patches' => 'server-patching-overview-section', 'terminal-access' => 'server-terminal-access-section', 'host-key' => 'server-host-key-section'] as $page => $section) { + Route::get("/security/{$page}", fn (string $server_uuid) => redirect(route('server.security', ['server_uuid' => $server_uuid]).'#'.$section)) + ->name("server.security.{$page}"); + } }); Route::get('/destinations', DestinationIndex::class)->name('destination.index'); Route::get('/destination/{destination_uuid}', DestinationShow::class)->name('destination.show'); @@ -548,8 +576,10 @@ Route::middleware(['auth', 'verified'])->group(function () { // Route::get('/security', fn () => view('security.index'))->name('security.index'); Route::get('/security/private-key', SecurityPrivateKeyIndex::class)->name('security.private-key.index'); + // The key page was merged into the list; keep old bookmarks working. + Route::get('/security/private-key/{private_key_uuid}', fn () => redirect()->route('security.private-key.index')) + ->name('security.private-key.show'); // Route::get('/security/private-key/new', SecurityPrivateKeyCreate::class)->name('security.private-key.create'); - Route::get('/security/private-key/{private_key_uuid}', SecurityPrivateKeyShow::class)->name('security.private-key.show'); Route::get('/security/cloud-tokens', CloudTokens::class)->name('security.cloud-tokens'); Route::get('/security/integration-tokens', IntegrationTokens::class)->name('security.integration-tokens'); @@ -564,6 +594,17 @@ Route::middleware(['auth'])->group(function () { Route::get('/select-team', SelectTeam::class)->name('team.select'); }); +// Called by the terminal server when a PTY ends, also after a logout. The secret session id authenticates it. +Route::post('/terminal/session/end', function (Request $request, TerminalSessionService $terminalSessionService) { + $request->validate([ + 'session_id' => ['required', 'string', 'size:64'], + 'reason' => ['required', 'string', 'in:exited,disconnected,replaced,rejected,idle,max_duration,revoked'], + ]); + $terminalSessionService->end($request->string('session_id')->toString(), $request->string('reason')->toString()); + + return response()->noContent(); +})->name('terminal.session.end')->withoutMiddleware(VerifyCsrfToken::class); + Route::middleware(['auth', 'access.team-wide'])->group(function () { Route::get('/sources', function () { $sources = currentTeam()->sources(); @@ -584,27 +625,14 @@ Route::middleware(['auth'])->group(function () { Route::post('/upload/backup/{databaseUuid}', [UploadController::class, 'upload'])->name('upload.backup'); Route::get('/download/backup/{executionId}', function () { try { - $user = auth()->user(); - $team = $user->currentTeam(); - if (is_null($team)) { - return response()->json(['message' => 'Team not found.'], 404); - } - if ($user->isAdminFromSession() === false) { - return response()->json(['message' => 'Only team admins/owners can download backups.'], 403); - } $exeuctionId = request()->route('executionId'); $execution = ScheduledDatabaseBackupExecution::where('id', $exeuctionId)->firstOrFail(); $execution_team_id = $execution->scheduledDatabaseBackup->database->team()?->id; - if ($team->id !== 0) { - if (is_null($execution_team_id)) { - return response()->json(['message' => 'Team not found.'], 404); - } - if ($team->id !== $execution_team_id) { - return response()->json(['message' => 'Permission denied.'], 403); - } - if (is_null($execution)) { - return response()->json(['message' => 'Backup not found.'], 404); - } + if (is_null($execution_team_id)) { + return response()->json(['message' => 'Team not found.'], 404); + } + if (! auth()->user()->isAdminOfTeam($execution_team_id)) { + return response()->json(['message' => 'Only team admins/owners can download backups.'], 403); } $filename = data_get($execution, 'filename'); if (! $execution->local_storage_deleted && filled($filename)) { @@ -622,11 +650,11 @@ Route::middleware(['auth'])->group(function () { } // Only storages of the team that owns the database can serve the copy. - $s3DownloadUrl = is_null($execution_team_id) + $s3Download = is_null($execution_team_id) ? null - : $execution->s3DownloadUrl($execution_team_id, $execution->scheduledDatabaseBackup->s3_storage_id); - if ($s3DownloadUrl) { - return redirect()->away($s3DownloadUrl); + : $execution->s3Download($execution_team_id, $execution->scheduledDatabaseBackup->s3_storage_id); + if ($s3Download) { + return $s3Download; } if ($execution->scheduledDatabaseBackup->disable_local_backup && $execution->scheduledDatabaseBackup->save_s3) { @@ -641,20 +669,15 @@ Route::middleware(['auth'])->group(function () { Route::get('/download/volume-backup/{executionId}', function () { try { - $user = auth()->user(); - $team = $user->currentTeam(); - if (is_null($team)) { - return response()->json(['message' => 'Team not found.'], 404); - } - if ($user->isAdminFromSession() === false) { - return response()->json(['message' => 'Only team admins/owners can download backups.'], 403); - } - $execution = ScheduledVolumeBackupExecution::query() ->with('scheduledVolumeBackup.backupable.resource') ->findOrFail(request()->route('executionId')); - if ($team->id !== 0 && $team->id !== $execution->scheduledVolumeBackup->team_id) { - return response()->json(['message' => 'Permission denied.'], 403); + $execution_team_id = $execution->scheduledVolumeBackup->team_id; + if (is_null($execution_team_id)) { + return response()->json(['message' => 'Team not found.'], 404); + } + if (! auth()->user()->isAdminOfTeam($execution_team_id)) { + return response()->json(['message' => 'Only team admins/owners can download backups.'], 403); } if (blank($execution->filename)) { return response()->json(['message' => 'Backup not found locally on the server.'], 404); @@ -670,9 +693,9 @@ Route::middleware(['auth'])->group(function () { } // Only storages of the team that owns the backup can serve the copy. - $s3DownloadUrl = $execution->s3DownloadUrl($execution->scheduledVolumeBackup->team_id, $execution->s3_storage_id); - if ($s3DownloadUrl) { - return redirect()->away($s3DownloadUrl); + $s3Download = $execution->s3Download($execution->scheduledVolumeBackup->team_id, $execution->s3_storage_id); + if ($s3Download) { + return $s3Download; } if (! $execution->local_storage_deleted && ! $server) { diff --git a/scripts/transfer-test/extras-e2e.sh b/scripts/transfer-test/extras-e2e.sh index 652ff577aa..49fcc9dde2 100755 --- a/scripts/transfer-test/extras-e2e.sh +++ b/scripts/transfer-test/extras-e2e.sh @@ -1,6 +1,6 @@ #!/usr/bin/env bash # Add fake records around debian-root on A (tokens, DNS, secret manager link, tunnel token, GitHub App + runner config, -# resource links, canvas positions, team variable, maintenance mode, proxy error page, project icon, and an app of the +# image scan opt-in, rules and an ignore, resource links, canvas positions, team variable, maintenance mode, proxy error page, project icon, and an app of the # same project on the other VM), transfer debian-root to B through the Transfer page, and check them on B and A. # Run reset.sh before and after. source "$(dirname "$0")/lib.sh" diff --git a/scripts/transfer-test/seed-extras.php b/scripts/transfer-test/seed-extras.php index 7059519335..21a4c6418f 100644 --- a/scripts/transfer-test/seed-extras.php +++ b/scripts/transfer-test/seed-extras.php @@ -10,6 +10,8 @@ use App\Models\EnvironmentVariable; use App\Models\GithubApp; use App\Models\GithubRunnerConfig; use App\Models\GithubRunnerExecution; +use App\Models\ImageScanIgnore; +use App\Models\ImageScanPolicy; use App\Models\IntegrationToken; use App\Models\ManagedDnsRecord; use App\Models\ResourceLink; @@ -79,6 +81,14 @@ SharedEnvironmentVariable::create(['type' => 'team', 'team_id' => $teamId, 'key' EnvironmentVariable::create(['key' => 'USES_TEAM', 'value' => '{{team.FAKE_TEAM_VAR}}', 'resourceable_type' => $app->getMorphClass(), 'resourceable_id' => $app->id]); $app->forceFill(['is_maintenance_enabled' => true, 'maintenance_page' => '

fake maintenance

'])->saveQuietly(); +// Image scan: server opt-in, server and app rules, and an ignore of the app +$server->settings->forceFill(['is_image_scan_enabled' => true])->saveQuietly(); +$quiet(ImageScanPolicy::class, ['scope_type' => $server->getMorphClass(), 'scope_id' => $server->id, 'gate_mode' => 'strict']); +$quiet(ImageScanPolicy::class, ['scope_type' => $app->getMorphClass(), 'scope_id' => $app->id, 'on_scan_error' => 'block']); +$quiet(ImageScanIgnore::class, ['team_id' => $teamId, 'resource_type' => $app->getMorphClass(), 'resource_id' => $app->id, 'vulnerability_id' => 'CVE-2024-0001', 'reason' => 'fake ignore']); +$quiet(ImageScanIgnore::class, ['team_id' => $teamId, 'vulnerability_id' => 'CVE-2024-0002', 'reason' => 'fake team-wide ignore']); +$app->settings->forceFill(['is_image_scan_enabled' => false, 'image_builds_by_team' => true])->saveQuietly(); + // A git application that uses a system-wide GitHub App, which the transfer does not move $systemApp = $quiet(GithubApp::class, ['name' => 'fake-system-wide-app', 'api_url' => 'https://api.github.com', 'html_url' => 'https://github.com', 'team_id' => 0, 'is_system_wide' => true, 'is_public' => false]); $gitApp = Application::create([ diff --git a/scripts/transfer-test/verify-extras-target.php b/scripts/transfer-test/verify-extras-target.php index 0a5d57a43f..969d4ee8e7 100644 --- a/scripts/transfer-test/verify-extras-target.php +++ b/scripts/transfer-test/verify-extras-target.php @@ -6,6 +6,8 @@ use App\Models\Application; use App\Models\Environment; use App\Models\GithubApp; use App\Models\GithubRunnerConfig; +use App\Models\ImageScanIgnore; +use App\Models\ImageScanPolicy; use App\Models\IntegrationToken; use App\Models\ManagedDnsRecord; use App\Models\ResourceLink; @@ -63,6 +65,14 @@ $check('team shared variable created', SharedEnvironmentVariable::where('type', $check('app variable that uses it moved', $app?->environment_variables()->where('key', 'USES_TEAM')->value('value') === '{{team.FAKE_TEAM_VAR}}'); $check('maintenance mode moved', $app?->is_maintenance_enabled === true && $app?->maintenance_page === '

fake maintenance

'); +$check('image scan opt-in of the server moved', $server?->settings?->is_image_scan_enabled === true); +$serverPolicy = ImageScanPolicy::where('scope_type', $server?->getMorphClass())->where('scope_id', $server?->id)->first(); +$check('server scan rules moved', $serverPolicy?->gate_mode?->value === 'strict'); +$check('app scan rules moved', ImageScanPolicy::where('scope_type', $app?->getMorphClass())->where('scope_id', $app?->id)->first()?->on_scan_error?->value === 'block'); +$check('app scan switch and image builds by team moved', $app?->settings?->is_image_scan_enabled === false && $app->settings->image_builds_by_team === true); +$check('team-wide ignore stays on the source', ! ImageScanIgnore::where('vulnerability_id', 'CVE-2024-0002')->exists()); +$check('app ignore moved', ImageScanIgnore::where('resource_type', $app?->getMorphClass())->where('resource_id', $app?->id)->where('vulnerability_id', 'CVE-2024-0001')->exists()); + $gitShop = Application::where('name', 'git-shop')->first(); $check('git-shop moved without its system-wide source', $gitShop !== null && $gitShop->source_id === null); $check('finish list has a git source step for git-shop', collect(data_get($server?->server_metadata, 'transfer.finish.items'))->contains('key', 'git_source:'.$gitShop?->uuid)); diff --git a/scripts/upgrade.sh b/scripts/upgrade.sh index af8d12c67a..68e0713c82 100644 --- a/scripts/upgrade.sh +++ b/scripts/upgrade.sh @@ -14,6 +14,7 @@ else fi SKIP_BACKUP=${4:-false} STATUS_FILE="/data/coolify/source/.upgrade-status" +STATUS_LOCK_FILE="/data/coolify/source/.upgrade-status.lock" DATE=$(date +%Y-%m-%d-%H-%M-%S) LOGFILE="/data/coolify/source/upgrade-${DATE}.log" @@ -32,10 +33,17 @@ log_section() { } # Helper function to write upgrade status for API polling +# Holds the lock that Coolify takes when it writes an error before an upgrade starts, so neither write is lost. write_status() { - local step="$1" - local message="$2" - echo "${step}|${message}|$(date -Iseconds)" > "$STATUS_FILE" + local line="${1}|${2}|$(date -Iseconds)" + if command -v flock >/dev/null 2>&1; then + ( + flock -w 10 9 || true + echo "$line" > "$STATUS_FILE" + ) 9>"$STATUS_LOCK_FILE" + else + echo "$line" > "$STATUS_FILE" + fi } # Print the first gateway of a network that Docker reports with a prefix length (fd00::1/64). diff --git a/tests/Feature/Api/NotificationsApiTest.php b/tests/Feature/Api/NotificationsApiTest.php index d5f4908ed8..319a35dc56 100644 --- a/tests/Feature/Api/NotificationsApiTest.php +++ b/tests/Feature/Api/NotificationsApiTest.php @@ -9,6 +9,7 @@ use App\Models\Team; use App\Models\TelegramNotificationSettings; use App\Models\User; use App\Models\WebhookNotificationSettings; +use App\Rules\SafeWebhookUrl; use Illuminate\Foundation\Testing\RefreshDatabase; use Illuminate\Support\Once; @@ -35,6 +36,13 @@ beforeEach(function () { $this->token = $this->user->createToken('test-token', ['*']); $this->bearerToken = $this->token->plainTextToken; + + // Webhook URLs and the SMTP host are resolved during validation: answer without DNS. + SafeWebhookUrl::resolveHostsUsing(fn (): array => ['93.184.216.34']); +}); + +afterEach(function () { + SafeWebhookUrl::resolveHostsUsing(null); }); function authHeaders(string $token): array diff --git a/tests/Feature/Api/PrivateKeyUpdateApiTest.php b/tests/Feature/Api/PrivateKeyUpdateApiTest.php index a525d872d4..fd4799abcc 100644 --- a/tests/Feature/Api/PrivateKeyUpdateApiTest.php +++ b/tests/Feature/Api/PrivateKeyUpdateApiTest.php @@ -55,6 +55,17 @@ test('a private key update still replaces and validates the key when it is sent' expect($this->key->fresh()->fingerprint)->toBe(PrivateKey::generateFingerprint($newKey)); }); +test('a rejected key returns JSON 422 also when the client does not ask for JSON', function () { + $this->call('PATCH', '/api/v1/security/keys/'.$this->key->uuid, server: [ + 'CONTENT_TYPE' => 'application/json', + 'HTTP_AUTHORIZATION' => 'Bearer '.$this->bearerToken, + ], content: json_encode(['private_key' => 'not a key'])) + ->assertUnprocessable() + ->assertJson(['message' => 'The private key is invalid. Paste an unencrypted private key, not a public key or a certificate.']); + + expect($this->key->fresh()->fingerprint)->toBe(PrivateKey::generateFingerprint($this->keyMaterial)); +}); + test('a private key of another team cannot be renamed', function () { $otherTeam = Team::factory()->create(); $foreign = PrivateKey::create(['name' => 'Theirs', 'private_key' => generateSSHKey('ed25519')['private'], 'team_id' => $otherTeam->id]); diff --git a/tests/Feature/Api/S3StoragesApiTest.php b/tests/Feature/Api/S3StoragesApiTest.php index b8d5b6c17c..5bff9a5bcd 100644 --- a/tests/Feature/Api/S3StoragesApiTest.php +++ b/tests/Feature/Api/S3StoragesApiTest.php @@ -1,11 +1,14 @@ assertOk(); }); }); + +test('rejects an array storage type with 422', function () { + $response = $this->withToken($this->bearerToken) + ->postJson('/api/v1/s3-storages', validS3StoragePayload(['type' => ['sftp']])); + + $response->assertStatus(422); + $this->assertDatabaseCount('s3_storages', 0); +}); + +test('SFTP updates preserve the saved password for blank secrets', function ($secret) { + $storage = createS3StorageForTeam($this->team, [ + 'type' => BackupStorageType::Sftp, + 'endpoint' => 'sftp://backup.example.com/backups', + 'secret' => 'saved-password', + ]); + + $this->withToken($this->bearerToken) + ->patchJson('/api/v1/s3-storages/'.$storage->uuid, ['secret' => $secret]) + ->assertOk(); + + expect($storage->fresh()->secret)->toBe('saved-password'); +})->with([null, '', ' ']); + +function sftpStoragePayload(array $overrides = []): array +{ + return array_merge([ + 'type' => 'sftp', + 'name' => 'SFTP Storage', + 'endpoint' => 'sftp://1.1.1.1/backups', + 'key' => 'backup', + 'secret' => 'example-password', + ], $overrides); +} + +test('rejects a username or password in the storage endpoint', function (array $payload) { + $this->withToken($this->bearerToken) + ->postJson('/api/v1/s3-storages', $payload) + ->assertStatus(422) + ->assertJsonPath('errors.endpoint.0', 'The endpoint must not contain a username or password. Enter the credentials in their own fields.'); + + $this->assertDatabaseCount('s3_storages', 0); +})->with([ + 'sftp user and password' => [fn () => sftpStoragePayload(['endpoint' => 'sftp://user:secret@1.1.1.1/backups'])], + 'sftp user only' => [fn () => sftpStoragePayload(['endpoint' => 'sftp://user@1.1.1.1'])], + 'webdav user and password' => [fn () => sftpStoragePayload(['type' => 'webdav', 'endpoint' => 'https://user:secret@1.1.1.1/remote.php'])], + 's3 user and password' => [fn () => validS3StoragePayload(['endpoint' => 'https://user:secret@s3.amazonaws.com'])], +]); + +test('updates a saved storage whose endpoint already has a username and password', function () { + $storage = createS3StorageForTeam($this->team, ['endpoint' => 'https://user:secret@s3.amazonaws.com']); + + $this->withToken($this->bearerToken) + ->patchJson('/api/v1/s3-storages/'.$storage->uuid, ['name' => 'Renamed']) + ->assertOk(); + + expect($storage->fresh()->name)->toBe('Renamed'); +}); + +test('rejects control characters in the storage endpoint path', function (string $endpoint) { + $this->withToken($this->bearerToken) + ->postJson('/api/v1/s3-storages', sftpStoragePayload(['endpoint' => $endpoint])) + ->assertStatus(422) + ->assertJsonValidationErrors(['endpoint']); + + $this->assertDatabaseCount('s3_storages', 0); +})->with([ + 'encoded line feed' => 'sftp://1.1.1.1/a%0Ab', + 'encoded null byte' => 'sftp://1.1.1.1/a%00b', + 'encoded delete' => 'sftp://1.1.1.1/a%7Fb', +]); + +test('the base path of a storage never contains control characters', function () { + $storage = new S3Storage; + $storage->setRawAttributes(['type' => 'sftp', 'endpoint' => 'sftp://1.1.1.1/a%0Ab']); + + expect(fn () => $storage->basePath())->toThrow(RuntimeException::class); +}); + +test('rejects a git-related private key of the team for an SFTP storage', function () { + $gitKey = PrivateKey::factory()->create(['team_id' => $this->team->id, 'is_git_related' => true]); + + $this->withToken($this->bearerToken) + ->postJson('/api/v1/s3-storages', sftpStoragePayload(['secret' => null, 'private_key_uuid' => $gitKey->uuid])) + ->assertStatus(422) + ->assertJsonValidationErrors(['private_key_uuid']); + + $this->assertDatabaseCount('s3_storages', 0); +}); + +test('an SFTP storage does not log in with a git-related private key', function () { + $gitKey = PrivateKey::factory()->create(['team_id' => $this->team->id, 'is_git_related' => true]); + $storage = createS3StorageForTeam($this->team, [ + 'type' => BackupStorageType::Sftp, + 'endpoint' => 'sftp://1.1.1.1/backups', + 'private_key_id' => $gitKey->id, + 'secret' => '', + ]); + + expect(fn () => $storage->sftpPrivateKey())->toThrow(RuntimeException::class); +}); + +test('lists SFTP storages without a query per storage', function () { + $readToken = $this->user->createToken('read-token', ['read'])->plainTextToken; + $listQueryCount = function () use ($readToken): int { + DB::flushQueryLog(); + DB::enableQueryLog(); + $this->withToken($readToken)->getJson('/api/v1/s3-storages')->assertOk(); + DB::disableQueryLog(); + + return count(DB::getQueryLog()); + }; + $privateKey = PrivateKey::factory()->create(['team_id' => $this->team->id]); + $createSftpStorage = fn () => createS3StorageForTeam($this->team, [ + 'type' => BackupStorageType::Sftp, + 'endpoint' => 'sftp://1.1.1.1/backups', + 'private_key_id' => $privateKey->id, + 'secret' => '', + ]); + + $createSftpStorage(); + $listQueryCount(); + $queriesWithOneStorage = $listQueryCount(); + $createSftpStorage(); + $createSftpStorage(); + $listQueryCount(); + + expect($listQueryCount())->toBe($queriesWithOneStorage); +}); diff --git a/tests/Feature/Api/ServerValidationApiTest.php b/tests/Feature/Api/ServerValidationApiTest.php index 7764095d31..7d2e511d6b 100644 --- a/tests/Feature/Api/ServerValidationApiTest.php +++ b/tests/Feature/Api/ServerValidationApiTest.php @@ -57,3 +57,19 @@ it('rejects an invalid install option', function () { $response->assertUnprocessable()->assertJsonValidationErrors('install'); }); + +it('rejects non-string SSH host keys with 422', function (string $endpoint) { + $settingsBefore = $this->server->settings->getAttributes(); + $serverCount = Server::count(); + + $this->withHeaders(serverValidationHeaders()) + ->postJson($endpoint === 'create' ? '/api/v1/servers' : "/api/v1/servers/{$this->server->uuid}/host-key/reset", [ + 'ssh_host_key' => ['x'], + ]) + ->assertUnprocessable() + ->assertJsonValidationErrors('ssh_host_key'); + + expect(Server::count())->toBe($serverCount); + expect($this->server->settings->fresh()->getAttributes())->toBe($settingsBefore); + Queue::assertNothingPushed(); +})->with(['create', 'reset']); diff --git a/tests/Feature/Application/GithubSourceChangeTest.php b/tests/Feature/Application/GithubSourceChangeTest.php index 9dcf149f8c..c7776bd85e 100644 --- a/tests/Feature/Application/GithubSourceChangeTest.php +++ b/tests/Feature/Application/GithubSourceChangeTest.php @@ -686,6 +686,7 @@ describe('GitHub Source Change Component', function () { 'pull_requests' => 'write', ], ]), + 'https://api.github.com/app/installations/67890' => Http::response(['permissions' => []]), ]); Livewire::withQueryParams(['github_app_uuid' => $githubApp->uuid]) @@ -709,6 +710,40 @@ describe('GitHub Source Change Component', function () { ->and($githubApp->pull_requests)->toBe('write'); }); + test('checkPermissions does not let a member refetch permissions', function () { + $member = User::factory()->create(); + $this->team->members()->attach($member->id, ['role' => 'member']); + $privateKey = PrivateKey::create([ + 'name' => 'Test Key', + 'private_key' => validPrivateKey(), + 'team_id' => $this->team->id, + ]); + $githubApp = GithubApp::create([ + 'name' => 'Test GitHub App', + 'api_url' => 'https://api.github.com', + 'html_url' => 'https://github.com', + 'custom_user' => 'git', + 'custom_port' => 22, + 'app_id' => 12345, + 'installation_id' => 67890, + 'private_key_id' => $privateKey->id, + 'team_id' => $this->team->id, + 'is_system_wide' => false, + ]); + Http::preventStrayRequests(); + Http::fake(); + + Livewire::actingAs($member) + ->withQueryParams(['github_app_uuid' => $githubApp->uuid]) + ->test(Change::class) + ->call('checkPermissions') + ->assertNotDispatched('success'); + + Http::assertNothingSent(); + expect($githubApp->fresh()->contents)->toBeNull() + ->and($githubApp->fresh()->deployments)->toBeNull(); + }); + test('sync name uses normalized resolvable ghe dot com api url', function () { $privateKey = PrivateKey::create([ 'name' => 'Test Key', diff --git a/tests/Feature/ApplicationGitSshHostKeyScanTest.php b/tests/Feature/ApplicationGitSshHostKeyScanTest.php new file mode 100644 index 0000000000..45212cba9f --- /dev/null +++ b/tests/Feature/ApplicationGitSshHostKeyScanTest.php @@ -0,0 +1,115 @@ +team = $team = Team::factory()->create(); + $this->privateKey = $privateKey = PrivateKey::factory()->create(['team_id' => $team->id]); + $this->server = $server = Server::factory()->create(['team_id' => $team->id, 'private_key_id' => $privateKey->id]); + $this->destination = StandaloneDocker::where('server_id', $server->id)->first(); + $this->destination->update(['network' => 'git-net']); + $environment = Environment::factory()->create(['project_id' => Project::factory()->create(['team_id' => $team->id])->id]); + + $this->application = Application::factory()->create([ + 'environment_id' => $environment->id, + 'destination_id' => $this->destination->id, + 'destination_type' => $this->destination->getMorphClass(), + 'git_repository' => 'git@gitea:org/repo.git', + 'private_key_id' => $privateKey->id, + 'source_id' => null, + 'source_type' => null, + ]); +}); + +test('the host key scan runs on the destination network and ends the options before the host', function () { + $scripts = []; + fakeRemoteProcesses(['*' => function (PendingProcess $process) use (&$scripts) { + $scripts[] = remoteProcessScript($process); + + return Process::result('gitea ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIExample'); + }]); + + expect($this->application->fetchGitSshHostKeys())->toBe(['gitea ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIExample']) + ->and(implode("\n", $scripts))->toContain("docker run --rm --network 'git-net' --entrypoint ssh-keyscan") + ->and(implode("\n", $scripts))->toContain("-T 10 -p 22 -- 'gitea'"); +}); + +test('the host key scan also reads the saved hosts, such as submodule hosts, but not the pinned hosts', function () { + $this->application->update(['git_ssh_known_hosts' => "gitea ssh-ed25519 OLD\n[sub.example.com]:2222 ssh-ed25519 OLDSUB"]); + $scripts = []; + fakeRemoteProcesses(['*' => function (PendingProcess $process) use (&$scripts) { + $scripts[] = remoteProcessScript($process); + + return Process::result('[sub.example.com]:2222 ssh-ed25519 NEWSUB'); + }]); + + expect($this->application->gitSshHostsToScan())->toBe([['host' => 'gitea', 'port' => 22], ['host' => 'sub.example.com', 'port' => 2222]]) + ->and($this->application->fetchGitSshHostKeys())->toBe(['[sub.example.com]:2222 ssh-ed25519 NEWSUB']) + ->and(implode("\n", $scripts))->toContain("-T 10 -p 2222 -- 'sub.example.com'"); + + $this->application->update(['git_repository' => 'git@github.com:org/repo.git', 'git_ssh_known_hosts' => null]); + expect($this->application->fresh()->gitSshHostsToScan())->toBe([]); +}); + +test('the host key scan runs on the build server when the application builds there', function () { + $buildServer = Server::factory()->create(['team_id' => $this->team->id, 'private_key_id' => $this->privateKey->id, 'ip' => '192.0.2.77']); + $buildServer->settings->update(['server_role' => ServerRole::BUILD, 'is_reachable' => true, 'is_usable' => true]); + $this->application->settings->update(['is_build_server_enabled' => true]); + $scripts = []; + fakeRemoteProcesses(['*' => function (PendingProcess $process) use (&$scripts) { + $scripts[] = $process->command."\n".remoteProcessScript($process); + + return Process::result('gitea ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIExample'); + }]); + + expect($this->application->fresh()->fetchGitSshHostKeys())->toBe(['gitea ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIExample']) + ->and($scans = array_values(array_filter($scripts, fn (string $script) => str_contains($script, 'ssh-keyscan'))))->toHaveCount(1) + ->and($scans[0])->toContain('192.0.2.77') + ->and($scans[0])->toContain('docker run --rm --entrypoint ssh-keyscan'); +}); + +test('a git host that starts with a dash is not an ssh host', function (string $repository) { + $this->application->update(['git_repository' => $repository]); + + expect($this->application->gitSshHost())->toBeNull(); +})->with([ + 'scp style' => 'git@-oProxy:org/repo.git', + 'ssh url' => 'ssh://git@-oProxy:22/org/repo.git', +]); + +test('a host key verification failure tells the user to review the host key', function () { + $reason = (fn (string $summary, string $errorOutput) => $this->gitFailureReason($summary, $errorOutput)) + ->call($this->application, 'Failed to read Git source.', "Host key verification failed.\nfatal: Could not read from remote repository.\n\nPlease make sure you have the correct access rights\nand the repository exists."); + + expect($reason)->toContain('Source > SSH host keys') + ->and($reason)->toContain('Fetch current host key') + ->and($reason)->not->toContain('and the repository exists.'); +}); + +test('other git failures still show the last error line', function () { + $reason = (fn (string $summary, string $errorOutput) => $this->gitFailureReason($summary, $errorOutput)) + ->call($this->application, 'Failed to read Git source.', "fatal: bad ref\n"); + + expect($reason)->toBe('

Reason: fatal: <b>bad</b> ref'); +}); + +test('the server checkout writes the ssh config before git uses it', function () { + $command = $this->application->serverCheckoutCommand('test-uuid', '/tmp/checkout'); + + $configWrite = strpos($command, 'tee /root/.ssh/coolify_git_test-uuid.config'); + expect($configWrite)->toBeInt() + ->and($configWrite)->toBeLessThan(strpos($command, 'git clone')) + ->and($command)->toContain('ssh -F /root/.ssh/coolify_git_test-uuid.config'); +}); diff --git a/tests/Feature/ApplicationRollbackTest.php b/tests/Feature/ApplicationRollbackTest.php index 432bdde1bc..1558d5a28d 100644 --- a/tests/Feature/ApplicationRollbackTest.php +++ b/tests/Feature/ApplicationRollbackTest.php @@ -140,7 +140,8 @@ describe('Application Rollback', function () { ); expect($result) - ->toContain('GIT_SSH_COMMAND="ssh -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null" git lfs pull') + ->toContain('GIT_SSH_COMMAND="ssh -F /root/.ssh/coolify_git_test-uuid.config -o ConnectTimeout=30 -o ServerAliveInterval=15 -o ServerAliveCountMax=4 -o LogLevel=ERROR" git lfs pull') + ->not->toContain('StrictHostKeyChecking=no') ->not->toContain('-i /root/.ssh/id_rsa'); }); }); diff --git a/tests/Feature/ApplicationSourceCrossTeamTest.php b/tests/Feature/ApplicationSourceCrossTeamTest.php index 9dd7633d0f..4a24a464f7 100644 --- a/tests/Feature/ApplicationSourceCrossTeamTest.php +++ b/tests/Feature/ApplicationSourceCrossTeamTest.php @@ -287,3 +287,150 @@ test('changeSource rejects a GiteaApp owned by another team', function () { expect($this->applicationA->source_type)->not->toBe(GiteaApp::class); Http::assertNothingSent(); }); + +test('submit reports invalid SSH known hosts as a field error without saving', function () { + $originalKnownHosts = $this->applicationA->git_ssh_known_hosts; + + Livewire::test(Source::class, ['application' => $this->applicationA]) + ->set('gitSshKnownHosts', 'invalid-host-key') + ->call('submit') + ->assertHasErrors(['gitSshKnownHosts']) + ->assertDispatched('known-hosts-invalid') + ->assertNotDispatched('configurationChanged'); + + expect($this->applicationA->fresh()->git_ssh_known_hosts)->toBe($originalKnownHosts); +}); + +test('submit reports manual host keys of a pinned Git provider as a field error without saving', function (string $line) { + $this->applicationA->update(['git_ssh_known_hosts' => 'git.example.com ssh-ed25519 AAAA']); + + Livewire::test(Source::class, ['application' => $this->applicationA]) + ->set('gitSshKnownHosts', "git.example.com ssh-ed25519 AAAA\n{$line}") + ->call('submit') + ->assertHasErrors(['gitSshKnownHosts']) + ->assertDispatched('known-hosts-invalid') + ->assertNotDispatched('configurationChanged'); + + expect($this->applicationA->fresh()->git_ssh_known_hosts)->toBe('git.example.com ssh-ed25519 AAAA'); +})->with([ + 'github.com' => 'github.com ssh-ed25519 BBBB', + 'bitbucket.org with a port' => '[altssh.bitbucket.org]:443 ssh-ed25519 BBBB', + 'ssh.github.com on port 443' => '[ssh.github.com]:443 ssh-rsa BBBB', + 'bitbucket.org in a host list' => 'git.example.com,BITBUCKET.ORG ssh-ed25519 BBBB', +]); + +test('saving the source form does not reject pinned provider host keys that were already saved', function () { + Application::query()->whereKey($this->applicationA->id)->update(['git_ssh_known_hosts' => "git.example.com ssh-ed25519 AAAA\ngithub.com ssh-ed25519 BBBB"]); + $this->applicationA->refresh(); + + Livewire::test(Source::class, ['application' => $this->applicationA]) + ->set('gitBranch', 'other-branch') + ->call('submit') + ->assertHasNoErrors(); + + expect($this->applicationA->fresh()->git_branch)->toBe('other-branch'); +}); + +test('saving the source form keeps host keys that a deployment saved after the page opened', function () { + $component = Livewire::test(Source::class, ['application' => $this->applicationA]); + Application::query()->whereKey($this->applicationA->id)->update(['git_ssh_known_hosts' => 'git.example.com ssh-ed25519 AAAA']); + + $component->set('gitBranch', 'other-branch')->call('submit')->assertHasNoErrors(); + + expect($this->applicationA->fresh()) + ->git_branch->toBe('other-branch') + ->git_ssh_known_hosts->toBe('git.example.com ssh-ed25519 AAAA'); +}); + +test('saving the source form stores host keys that the user edited', function () { + $component = Livewire::test(Source::class, ['application' => $this->applicationA]); + Application::query()->whereKey($this->applicationA->id)->update(['git_ssh_known_hosts' => 'git.example.com ssh-ed25519 AAAA']); + + $component->set('gitSshKnownHosts', 'other.example.com ssh-ed25519 BBBB')->call('submit')->assertHasNoErrors(); + + expect($this->applicationA->fresh()->git_ssh_known_hosts)->toBe('other.example.com ssh-ed25519 BBBB'); +}); + +test('removing a host key keeps another host that has the same key', function () { + $this->applicationA->update(['git_ssh_known_hosts' => "git.example.com ssh-ed25519 AAAA\n[git.example.com]:2222 ssh-ed25519 AAAA"]); + $component = Livewire::test(Source::class, ['application' => $this->applicationA]); + $keys = $component->instance()->knownHostFingerprints(); + + expect(array_unique(array_column($keys, 'id')))->toHaveCount(2); + + $component->call('removeHostKey', $keys[1]['id']); + + expect($this->applicationA->fresh()->git_ssh_known_hosts)->toBe('git.example.com ssh-ed25519 AAAA'); +}); + +test('a member or a user of another team cannot fetch, accept, or remove host keys', function (string $role) { + $saved = 'git.example.com ssh-ed25519 AAAA'; + $this->applicationA->update(['git_repository' => 'git@git.example.com:org/repo.git', 'git_ssh_known_hosts' => $saved]); + $user = User::factory()->create(); + $team = $role === 'member' ? $this->teamA : $this->teamB; + $team->members()->attach($user->id, ['role' => $role === 'member' ? 'member' : 'owner']); + $this->actingAs($user); + session(['currentTeam' => $team]); + + $component = Livewire::test(Source::class, ['application' => $this->applicationA]); + $component->call('fetchHostKeys')->assertDispatched('error')->assertSet('fetchedHostKeys', []); + $component->call('acceptFetchedHostKeys')->assertDispatched('error'); + $component->call('removeHostKey', sha1($saved))->assertDispatched('error'); + + expect($this->applicationA->fresh()->git_ssh_known_hosts)->toBe($saved); +})->with(['member', 'other team owner']); + +test('the ssh host keys section shows only when git uses ssh or keys are saved', function (string $repository, ?string $saved, bool $shown) { + $this->applicationA->update(['git_repository' => $repository, 'git_ssh_known_hosts' => $saved]); + + $component = Livewire::test(Source::class, ['application' => $this->applicationA]); + + $shown ? $component->assertSee('SSH host keys') : $component->assertDontSee('SSH host keys'); +})->with([ + 'https repository' => ['https://github.com/coollabsio/coolify-examples', null, false], + 'ssh repository' => ['git@git.example.com:org/repo.git', null, true], + 'https repository with saved submodule keys' => ['https://github.com/coollabsio/coolify-examples', 'git.example.com ssh-ed25519 AAAA', true], +]); + +test('fetch reads the published host keys of GitHub and Bitbucket for deployments', function () { + $this->applicationA->update(['git_repository' => 'git@github.com:org/repo.git']); + Http::fake([ + 'api.github.com/meta' => Http::response(['ssh_keys' => ['ssh-ed25519 AAAANEWGITHUB']]), + ]); + + Livewire::test(Source::class, ['application' => $this->applicationA]) + ->call('fetchHostKeys') + ->assertDispatched('success') + ->assertSet('fetchedHostKeys', []); + + $keys = gitProviderHostKeys(); + expect($keys)->toContain('github.com,ssh.github.com,[ssh.github.com]:443 ssh-ed25519 AAAANEWGITHUB') + ->and(collect($keys)->filter(fn (string $line) => str_starts_with($line, 'github.com,')))->toHaveCount(1) + ->and(collect($keys)->filter(fn (string $line) => str_starts_with($line, 'bitbucket.org,')))->toHaveCount(3) + ->and(gitSshSetupCommand('app'))->toContain(base64_encode(implode("\n", $keys)."\n")); +}); + +test('fetch keeps the built-in host keys when the provider sends no valid keys', function (mixed $response) { + $this->applicationA->update(['git_repository' => 'git@bitbucket.org:org/repo.git']); + Http::fake(['bitbucket.org/site/ssh' => $response]); + + Livewire::test(Source::class, ['application' => $this->applicationA]) + ->call('fetchHostKeys') + ->assertDispatched('error'); + + expect(gitProviderHostKeys())->toBe(GIT_PINNED_HOST_KEYS); +})->with([ + 'server error' => fn () => Http::response('', 500), + 'only other hosts' => fn () => Http::response("ssh.bitbucket.org ssh-ed25519 AAAAOTHER\n"), +]); + +test('the ssh host keys section lists the published keys of a GitHub repository without a remove button', function () { + $this->applicationA->update(['git_repository' => 'git@github.com:org/repo.git', 'git_ssh_known_hosts' => null]); + $published = gitKnownHostFingerprint(GIT_PINNED_HOST_KEYS[0]); + + Livewire::test(Source::class, ['application' => $this->applicationA]) + ->assertSee('Published by the provider, used for every deployment') + ->assertSee($published['fingerprint']) + ->assertDontSee('No saved host keys') + ->assertDontSee('Remove host key of github.com'); +}); diff --git a/tests/Feature/ApplicationSourceLocalhostKeyTest.php b/tests/Feature/ApplicationSourceLocalhostKeyTest.php index 8b5ef44960..4f5b148040 100644 --- a/tests/Feature/ApplicationSourceLocalhostKeyTest.php +++ b/tests/Feature/ApplicationSourceLocalhostKeyTest.php @@ -56,7 +56,7 @@ describe('Application Source with localhost key (id=0)', function () { ->assertSee('Deploy key'); }); - test('shows no source connected section when private_key_id is null', function () { + test('shows no deploy key section when private_key_id is null', function () { $application = Application::factory()->create([ 'environment_id' => $this->environment->id, 'private_key_id' => null, @@ -65,8 +65,7 @@ describe('Application Source with localhost key (id=0)', function () { Livewire::test(Source::class, ['application' => $application]) ->assertSuccessful() ->assertSet('privateKeyId', null) - ->assertDontSee('Deploy key') - ->assertSee('No source connected'); + ->assertDontSee('Deploy key'); }); test('dispatches configuration changed when source settings are saved', function () { diff --git a/tests/Feature/AuditEventsTest.php b/tests/Feature/AuditEventsTest.php index 091007e8aa..879fe07fc6 100644 --- a/tests/Feature/AuditEventsTest.php +++ b/tests/Feature/AuditEventsTest.php @@ -41,6 +41,7 @@ use Illuminate\Support\Facades\Log; use Illuminate\Support\Facades\Schema; use Illuminate\Support\Once; use Livewire\Livewire; +use Psr\Log\LoggerInterface; class FailingAuditAttributeCast implements CastsAttributes { @@ -72,25 +73,39 @@ beforeEach(function () { Log::spy(); }); -test('audit inserts are deferred until after the response', function () { +test('audit rows survive a request that stops before the terminate phase', function () { $this->withDefer(); - auditLog('ui.project.updated', [ + DB::transaction(fn () => Project::factory()->create([ 'team_id' => $this->team->id, - 'project_uuid' => 'project-123', - 'project_name' => 'Website', + 'name' => 'Website', + ])); + auditLog('ui.project.updated', ['team_id' => $this->team->id]); + + // PHP stopped the script (client abort, fatal error), so the deferred callbacks never run. + app()->forgetScopedInstances(); + + expect(AuditEvent::query()->pluck('event')->all())->toBe([ + 'ui.project.created', + 'ui.environment.created', + 'ui.project.updated', ]); - - expect(AuditEvent::query()->count())->toBe(0); - - defer()->invoke(); - - expect(AuditEvent::query()->count())->toBe(1); }); -test('multiple audit inserts in one request are all deferred', function () { - $this->withDefer(); +test('audit rows of a rolled back transaction are not written', function () { + try { + DB::transaction(function () { + Project::factory()->create(['team_id' => $this->team->id]); + throw new RuntimeException('rollback'); + }); + } catch (RuntimeException) { + } + + expect(AuditEvent::query()->count())->toBe(0); +}); + +test('multiple audit inserts in one request are all written in order', function () { auditLog('ui.application.deployed', [ 'team_id' => $this->team->id, 'application_uuid' => 'app-123', @@ -100,8 +115,6 @@ test('multiple audit inserts in one request are all deferred', function () { 'application_uuid' => 'app-123', ]); - defer()->invoke(); - expect(AuditEvent::query()->pluck('event')->all())->toBe([ 'ui.application.deployed', 'ui.application.updated', @@ -127,6 +140,22 @@ test('audit persistence failures do not fail the action', function () { } }); +test('audit logger failures do not fail the action', function (bool $inTransaction) { + DB::commit(); + Schema::rename('audit_events', 'unavailable_audit_events'); + Log::swap(Mockery::mock(LoggerInterface::class)); + Log::shouldReceive('warning')->once()->withAnyArgs()->andThrow(new RuntimeException('logger unavailable')); + + try { + $action = fn () => AuditEvent::record('ui.project.updated', ['team_id' => $this->team->id]); + + expect(fn () => $inTransaction ? DB::transaction($action) : $action()) + ->not->toThrow(Throwable::class); + } finally { + Schema::rename('unavailable_audit_events', 'audit_events'); + } +})->with([false, true]); + test('audit preparation failures log sanitized diagnostics without failing the action', function () { $resourceName = new class { @@ -381,7 +410,7 @@ test('audit change preparation failures do not fail model writes', function () { $project->update(['description' => 'Persist this description']); expect(Project::query()->findOrFail($project->id)->description)->toBe('Persist this description') - ->and(AuditEvent::query()->sole()->changes)->toBe([]); + ->and(AuditEvent::query()->sole()->changes)->toBeNull(); Log::shouldHaveReceived('warning')->once()->with( 'Audit change preparation failed', @@ -1284,8 +1313,6 @@ test('updating an environment variable records an event without its value', func }); test('creating an application environment variable records an audit event', function () { - $this->withDefer(); - $project = Project::factory()->create(['team_id' => $this->team->id]); $environment = Environment::factory()->create(['project_id' => $project->id]); $application = Application::factory()->create(['environment_id' => $environment->id]); @@ -1295,8 +1322,6 @@ test('creating an application environment variable records an audit event', func 'value' => 'secret-value', ]); - defer()->invoke(); - $event = AuditEvent::query() ->where('resource_type', 'environment_variable') ->where('action', 'created') diff --git a/tests/Feature/Authorization/DashboardAuthorizationTest.php b/tests/Feature/Authorization/DashboardAuthorizationTest.php index 12c9c16efe..6e615391e2 100644 --- a/tests/Feature/Authorization/DashboardAuthorizationTest.php +++ b/tests/Feature/Authorization/DashboardAuthorizationTest.php @@ -1,6 +1,7 @@ 0]); +}); + function setupDashboardUser(string $role): array { $team = Team::factory()->create(); diff --git a/tests/Feature/Authorization/LivewireStoredSecretAuthorizationTest.php b/tests/Feature/Authorization/LivewireStoredSecretAuthorizationTest.php index d1b1cde601..18794e689b 100644 --- a/tests/Feature/Authorization/LivewireStoredSecretAuthorizationTest.php +++ b/tests/Feature/Authorization/LivewireStoredSecretAuthorizationTest.php @@ -9,6 +9,7 @@ use App\Models\PrivateKey; use App\Models\S3Storage; use App\Models\Team; use App\Models\User; +use Illuminate\Filesystem\FilesystemAdapter; use Illuminate\Foundation\Testing\RefreshDatabase; use Illuminate\Support\Facades\Storage; use Livewire\Livewire; @@ -38,11 +39,13 @@ it('does not serialize a private key for a member who cannot update it', functio Livewire::test(PrivateKeyShow::class, ['private_key_uuid' => $privateKey->uuid]) ->assertSuccessful() - ->assertSet('privateKeyValue', '') - ->assertDontSee($privateKeyValue); + ->assertSet('newPrivateKey', '', strict: true) + ->assertDontSee($privateKeyValue) + ->call('revealPrivateKey', 'password') + ->assertReturned(null); }); -it('keeps a private key available to an owner who can update it', function () { +it('does not serialize a private key for an owner until it is revealed', function () { $privateKeyValue = PrivateKey::generateNewKeyPair('ed25519')['private_key']; $privateKey = PrivateKey::factory()->create([ 'team_id' => $this->team->id, @@ -54,7 +57,10 @@ it('keeps a private key available to an owner who can update it', function () { Livewire::test(PrivateKeyShow::class, ['private_key_uuid' => $privateKey->uuid]) ->assertSuccessful() - ->assertSet('privateKeyValue', $privateKeyValue); + ->assertSet('newPrivateKey', '', strict: true) + ->assertDontSee($privateKeyValue) + ->call('revealPrivateKey', 'password') + ->assertReturned($privateKeyValue); }); it('does not serialize GitHub App secrets for a member who cannot update it', function () { @@ -141,6 +147,7 @@ it('does not serialize S3 credentials for a member after any storage action', fu Livewire::test(StorageForm::class, ['storage' => $storage]) ->assertSet('key', '') ->assertSet('secret', '') + ->assertSet('hasSavedSecret', false) ->call('submit') ->assertSet('key', '') ->assertSet('secret', '') @@ -148,15 +155,36 @@ it('does not serialize S3 credentials for a member after any storage action', fu ->assertDontSee('stored-secret-key'); }); -it('keeps S3 credentials available to an owner who can update the storage', function () { +it('lets an owner save S3 settings with the saved secret without sending it to the browser', function () { + if (! defined('CURLOPT_RESOLVE')) { + define('CURLOPT_RESOLVE', 10203); + } $storage = createS3StorageWithSecrets($this->team); + $storage->update(['endpoint' => 'https://1.1.1.1']); + $disk = Mockery::mock(FilesystemAdapter::class); + $disk->allows('files')->andReturn([]); + Storage::shouldReceive('build') + ->atLeast()->once() + ->with(Mockery::on(fn (array $config): bool => $config['secret'] === 'stored-secret-key')) + ->andReturn($disk); $this->actingAs($this->owner); session(['currentTeam' => $this->team]); Livewire::test(StorageForm::class, ['storage' => $storage]) ->assertSet('key', 'stored-access-key') - ->assertSet('secret', 'stored-secret-key'); + ->assertSet('secret', '') + ->assertSet('hasSavedSecret', true) + ->assertDontSee('stored-secret-key') + ->set('name', 'Renamed storage') + ->call('submit') + ->assertHasNoErrors() + ->assertSet('secret', '') + ->assertDontSee('stored-secret-key'); + + $storage->refresh(); + expect($storage->name)->toBe('Renamed storage') + ->and($storage->secret)->toBe('stored-secret-key'); }); it('hides the S3 credential fields for a member after the session switches to a team they own', function () { diff --git a/tests/Feature/Authorization/NotificationAuthorizationTest.php b/tests/Feature/Authorization/NotificationAuthorizationTest.php index 89dca49fcd..bb13871a45 100644 --- a/tests/Feature/Authorization/NotificationAuthorizationTest.php +++ b/tests/Feature/Authorization/NotificationAuthorizationTest.php @@ -270,70 +270,6 @@ test('member cannot update resend email transport directly', function () { ->assertForbidden(); }); -test('member cannot copy instance email settings', function () { - $this->actingAs($this->member); - session(['currentTeam' => $this->team]); - - Livewire::test(EmailNotification::class) - ->call('copyFromInstanceSettings') - ->assertForbidden(); -}); - -test('team admin cannot copy instance email credentials', function () { - InstanceSettings::query()->findOrFail(0)->update([ - 'smtp_password' => 'instance-smtp-secret', - 'resend_api_key' => 'instance-resend-secret', - ]); - - $this->actingAs($this->admin); - session(['currentTeam' => $this->team]); - - Livewire::test(EmailNotification::class) - ->assertSet('smtpPassword', null) - ->assertSet('resendApiKey', null) - ->call('copyFromInstanceSettings') - ->assertForbidden(); - - expect($this->team->emailNotificationSettings->fresh()->smtp_password)->toBeNull(); - expect($this->team->emailNotificationSettings->fresh()->resend_api_key)->toBeNull(); -}); - -test('instance admin can copy instance email credentials to an authorized team', function () { - $rootTeam = Team::factory()->create(['id' => 0]); - $this->admin->teams()->attach($rootTeam, ['role' => 'admin']); - - InstanceSettings::query()->findOrFail(0)->update([ - 'smtp_from_address' => 'instance@example.com', - 'smtp_from_name' => 'Instance', - 'smtp_password' => 'instance-smtp-secret', - 'resend_api_key' => 'instance-resend-secret', - ]); - - $this->actingAs($this->admin); - session(['currentTeam' => $this->team]); - - Livewire::test(EmailNotification::class) - ->call('copyFromInstanceSettings') - ->assertHasNoErrors() - ->assertSet('smtpPassword', 'instance-smtp-secret') - ->assertSet('resendApiKey', 'instance-resend-secret'); - - expect($this->team->emailNotificationSettings->fresh()->smtp_password)->toBe('instance-smtp-secret'); - expect($this->team->emailNotificationSettings->fresh()->resend_api_key)->toBe('instance-resend-secret'); -}); - -test('instance admin cannot copy credentials to a team they cannot update', function () { - $rootTeam = Team::factory()->create(['id' => 0]); - $this->member->teams()->attach($rootTeam, ['role' => 'admin']); - - $this->actingAs($this->member); - session(['currentTeam' => $this->team]); - - Livewire::test(EmailNotification::class) - ->call('copyFromInstanceSettings') - ->assertForbidden(); -}); - test('admin can update email notification settings', function () { $this->actingAs($this->admin); session(['currentTeam' => $this->team]); @@ -360,11 +296,8 @@ test('admin can save team smtp settings without a resend api key when resend is session(['currentTeam' => $this->team]); Livewire::test(EmailNotification::class) - ->set('smtpFromAddress', 'alerts@example.com') - ->set('smtpFromName', 'Coolify') - ->set('smtpHost', 'smtp.example.com') - ->set('smtpPort', '587') - ->set('smtpEncryption', 'starttls') + ->set('resendFromAddress', 'alerts@example.com') + ->set('resendFromName', 'Coolify') ->set('resendEnabled', false) ->set('resendApiKey', null) ->call('submitResend') @@ -486,9 +419,9 @@ test('member cannot send test on any notification channel', function () { expect($this->member->can('sendTest', $this->team->webhookNotificationSettings))->toBeFalse(); }); -test('member cannot view notification secrets', function (string $component, string $settingsRelation, array $secrets) { +test('member cannot view notification secrets', function (string $component, string $settingsRelation, array $secrets, array $state = []) { $settings = $this->team->{$settingsRelation}; - $settings->update($secrets); + $settings->update([...$secrets, ...$state]); $this->actingAs($this->member); session(['currentTeam' => $this->team]); @@ -525,7 +458,7 @@ test('member cannot view notification secrets', function (string $component, str 'email credentials' => [EmailNotification::class, 'emailNotificationSettings', [ 'smtp_password' => 'smtp-secret-password', 'resend_api_key' => 'resend-secret-api-key', - ]], + ], ['smtp_enabled' => true]], ]); test('admin can view notification secrets', function (string $component, string $settingsRelation, array $secrets) { diff --git a/tests/Feature/BackupTimeoutSecretRedactionTest.php b/tests/Feature/BackupTimeoutSecretRedactionTest.php index 757e2b9f47..19563b2e26 100644 --- a/tests/Feature/BackupTimeoutSecretRedactionTest.php +++ b/tests/Feature/BackupTimeoutSecretRedactionTest.php @@ -45,7 +45,8 @@ function backupRedactionServer(Team $team): Server { $server = Server::factory()->create(['team_id' => $team->id]); $server->update(['private_key_id' => PrivateKey::factory()->create(['team_id' => $team->id])->id]); - $server->settings()->update(['is_reachable' => true, 'is_usable' => true]); + // The test counts ssh attempts; the host key check would add a key scan. + $server->settings()->update(['is_reachable' => true, 'is_usable' => true, 'is_ssh_host_key_check_disabled' => true]); return $server->fresh(); } diff --git a/tests/Feature/CleanupHelperContainersJobRcloneTest.php b/tests/Feature/CleanupHelperContainersJobRcloneTest.php new file mode 100644 index 0000000000..bf249a7112 --- /dev/null +++ b/tests/Feature/CleanupHelperContainersJobRcloneTest.php @@ -0,0 +1,138 @@ +team = Team::factory()->create(); +}); + +afterEach(function () { + Carbon::setTestNow(); +}); + +/** + * @param array $containers name and creation time + */ +function fakeRcloneContainers(array $containers, array &$scripts): void +{ + $lines = collect($containers)->map(fn (array $container, int $index): string => json_encode([ + 'ID' => 'id'.$index, + 'Names' => $container[0], + 'Image' => 'docker.io/rclone/rclone:1.75.2', + 'CreatedAt' => $container[1].' +0000 UTC', + ]))->implode("\n"); + + Process::fake(function (PendingProcess $process) use ($lines, &$scripts) { + $scripts[] = $script = remoteProcessScript($process); + + return Process::result(str_contains($script, 'label=coolify.type=rclone') ? $lines : '[]'); + }); +} + +it('removes rclone containers of ended jobs, and keeps those of running jobs', function (string $user, string $docker) { + $server = Server::factory()->create([ + 'team_id' => $this->team->id, + 'user' => $user, + 'private_key_id' => PrivateKey::factory()->create(['team_id' => $this->team->id])->id, + ]); + $databaseBackup = ScheduledDatabaseBackup::create([ + 'frequency' => '0 0 * * *', + 'database_type' => StandalonePostgresql::class, + 'database_id' => 1, + 'team_id' => $this->team->id, + ]); + $databaseBackup->executions()->create(['uuid' => 'runningdb', 'status' => 'running']); + $databaseBackup->executions()->create(['uuid' => 'endeddb', 'status' => 'failed']); + $volumeBackup = ScheduledVolumeBackup::create([ + 'backupable_type' => StandalonePostgresql::class, + 'backupable_id' => 1, + 'team_id' => $this->team->id, + 'frequency' => 'daily', + ]); + $volumeExecution = $volumeBackup->executions()->create(['status' => 'running']); + $importOperation = (string) Str::uuid(); + Activity::create(['log_name' => 'default', 'description' => 'import', 'properties' => [ + 'operation' => 'database_import', + 'operation_uuid' => $importOperation, + 'status' => ProcessStatus::IN_PROGRESS->value, + ]]); + $scripts = []; + fakeRcloneContainers([ + ['backup-of-runningdb', '2026-11-01 10:00:00'], + ['backup-of-endeddb', '2026-11-01 10:00:00'], + ["volume-upload-{$volumeExecution->uuid}", '2026-11-01 10:00:00'], + ["s3-restore-{$importOperation}", '2026-11-01 10:00:00'], + ['s3-restore-'.Str::uuid(), '2026-11-01 10:00:00'], + ['backup-of-juststarted', '2026-11-01 11:55:00'], + ], $scripts); + + (new CleanupHelperContainersJob($server))->handle(); + + $removed = collect(range(0, 5))->filter(fn (int $index): bool => collect($scripts) + ->contains(fn (string $script): bool => str_contains($script, "{$docker} container rm -f 'id{$index}'")))->values()->all(); + expect($removed)->toBe([1, 4]); +})->with([ + 'root' => ['root', 'docker'], + 'non-root' => ['ubuntu', 'sudo docker'], +]); + +it('keeps the helper containers of running backups and imports and removes orphaned backup uploads', function () { + $server = Server::factory()->create([ + 'team_id' => $this->team->id, + 'private_key_id' => PrivateKey::factory()->create(['team_id' => $this->team->id])->id, + ]); + ScheduledDatabaseBackup::create([ + 'frequency' => '0 0 * * *', + 'database_type' => StandalonePostgresql::class, + 'database_id' => 1, + 'team_id' => $this->team->id, + ])->executions()->create(['uuid' => 'runningdb', 'status' => 'running']); + $volumeExecution = ScheduledVolumeBackup::create([ + 'backupable_type' => StandalonePostgresql::class, + 'backupable_id' => 1, + 'team_id' => $this->team->id, + 'frequency' => 'daily', + ])->executions()->create(['status' => 'running']); + $importOperation = (string) Str::uuid(); + Activity::create(['log_name' => 'default', 'description' => 'import', 'properties' => [ + 'operation' => 'database_import', + 'operation_uuid' => $importOperation, + 'status' => ProcessStatus::QUEUED->value, + ]]); + $scripts = []; + Process::fake(function (PendingProcess $process) use (&$scripts, $volumeExecution, $importOperation) { + $scripts[] = $script = remoteProcessScript($process); + + return Process::result(str_contains($script, 'coolify-helper') ? json_encode([ + ['ID' => 'running', 'Names' => 'backup-of-runningdb', 'Image' => 'ghcr.io/coollabsio/coolify-helper:1.0.13'], + ['ID' => 'running', 'Names' => "volume-backup-{$volumeExecution->uuid}", 'Image' => 'ghcr.io/coollabsio/coolify-helper:1.0.13'], + ['ID' => 'running', 'Names' => "backup-decompress-{$importOperation}", 'Image' => 'ghcr.io/coollabsio/coolify-helper:1.0.13'], + ['ID' => 'orphan', 'Names' => 'backup-of-endeddb', 'Image' => 'ghcr.io/coollabsio/coolify-helper:1.0.13'], + ]) : ''); + }); + + (new CleanupHelperContainersJob($server))->handle(); + + $removals = collect($scripts)->filter(fn (string $script): bool => str_contains($script, 'docker container rm -f'))->values(); + expect($removals)->toHaveCount(1) + ->and($removals->first())->toContain("docker container rm -f 'orphan'"); +}); diff --git a/tests/Feature/CleanupUnusedSshKeysTest.php b/tests/Feature/CleanupUnusedSshKeysTest.php new file mode 100644 index 0000000000..5074c242e8 --- /dev/null +++ b/tests/Feature/CleanupUnusedSshKeysTest.php @@ -0,0 +1,200 @@ +freezeTime(); +}); + +function unusedSshCleanupKey(string $uuid): PrivateKey +{ + return PrivateKey::withoutEvents(fn () => PrivateKey::forceCreate([ + 'uuid' => $uuid, 'name' => 'Cleanup test', 'private_key' => 'encrypted test key', 'team_id' => 1, + ])); +} + +function unusedSshCleanupFile(string $uuid): string +{ + $filename = 'ssh_key@'.$uuid; + Storage::disk('ssh-keys')->put($filename, 'test key'); + touch(Storage::disk('ssh-keys')->path($filename), now()->subDays(2)->timestamp); + + return $filename; +} + +it('removes unused and orphaned files but keeps encrypted database keys', function () { + $key = unusedSshCleanupKey('unused'); + $unused = unusedSshCleanupFile($key->uuid); + $orphan = unusedSshCleanupFile('orphan'); + Storage::disk('ssh-keys')->put('unrelated', 'keep'); + + app(CleanupUnusedSshKeys::class)->handle(); + + Storage::disk('ssh-keys')->assertMissing([$unused, $orphan]); + Storage::disk('ssh-keys')->assertExists('unrelated'); + $this->assertModelExists($key); + expect($key->fresh()->private_key)->toBe('encrypted test key'); +}); + +it('keeps recent files and both keys of a running rotation', function () { + $old = unusedSshCleanupKey('old'); + $new = unusedSshCleanupKey('new'); + $files = [unusedSshCleanupFile('old'), unusedSshCleanupFile('new')]; + Storage::disk('ssh-keys')->put('ssh_key@recent', 'keep'); + Cache::put(RotatePrivateKeyJob::cacheKey($old), ['status' => 'running', 'new_private_key_id' => $new->id]); + + app(CleanupUnusedSshKeys::class)->handle(); + + Storage::disk('ssh-keys')->assertExists([...$files, 'ssh_key@recent']); +}); + +it('does not delete a key while its writer holds the lock', function () { + $file = unusedSshCleanupFile('locked'); + $lock = fopen(Storage::disk('ssh-keys')->path($file).'.lock', 'c'); + flock($lock, LOCK_EX); + try { + app(CleanupUnusedSshKeys::class)->handle(); + Storage::disk('ssh-keys')->assertExists($file); + } finally { + fclose($lock); + } +}); + +it('keeps a key referenced by a server', function () { + $key = unusedSshCleanupKey('server-key'); + $file = unusedSshCleanupFile($key->uuid); + Server::withoutEvents(fn () => Server::factory()->create([ + 'uuid' => 'test-server', 'private_key_id' => $key->id, 'team_id' => 1, + ])); + + app(CleanupUnusedSshKeys::class)->handle(); + + Storage::disk('ssh-keys')->assertExists($file); +}); + +it('keeps a key referenced by an application or a git source', function (Closure $reference) { + $key = unusedSshCleanupKey('referenced-key'); + $file = unusedSshCleanupFile($key->uuid); + $reference($key); + + app(CleanupUnusedSshKeys::class)->handle(); + + Storage::disk('ssh-keys')->assertExists($file); +})->with([ + 'application' => [fn (PrivateKey $key) => Application::withoutEvents(fn () => Application::factory()->create(['uuid' => 'cleanup-app', 'private_key_id' => $key->id]))], + 'GitHub app' => [fn (PrivateKey $key) => GithubApp::withoutEvents(fn () => GithubApp::forceCreate([ + 'uuid' => 'cleanup-github', 'name' => 'github', 'api_url' => 'https://api.github.com', 'html_url' => 'https://github.com', 'team_id' => 1, 'private_key_id' => $key->id, + ]))], + 'GitLab app' => [fn (PrivateKey $key) => GitlabApp::withoutEvents(fn () => GitlabApp::forceCreate([ + 'uuid' => 'cleanup-gitlab', 'name' => 'gitlab', 'api_url' => 'https://gitlab.example.com/api/v4', 'html_url' => 'https://gitlab.example.com', 'team_id' => 1, 'private_key_id' => $key->id, + ]))], + 'Gitea app' => [fn (PrivateKey $key) => GiteaApp::withoutEvents(fn () => GiteaApp::forceCreate([ + 'uuid' => 'cleanup-gitea', 'name' => 'gitea', 'api_url' => 'https://gitea.example.com/api/v1', 'html_url' => 'https://gitea.example.com', 'team_id' => 1, 'private_key_id' => $key->id, + ]))], +]); + +it('removes the file of a key that only a deleted server references', function () { + $key = unusedSshCleanupKey('deleted-server-key'); + $file = unusedSshCleanupFile($key->uuid); + $server = Server::withoutEvents(fn () => Server::factory()->create([ + 'uuid' => 'deleted-server', 'private_key_id' => $key->id, 'team_id' => 1, + ])); + Server::withoutEvents(fn () => $server->delete()); + + app(CleanupUnusedSshKeys::class)->handle(); + + Storage::disk('ssh-keys')->assertMissing($file); +}); + +it('uses a fixed number of queries for any number of key files', function () { + foreach (range(1, 40) as $id) { + $key = unusedSshCleanupKey('bounded-'.$id); + unusedSshCleanupFile($key->uuid); + if ($id % 2 === 0) { + Server::withoutEvents(fn () => Server::factory()->create([ + 'uuid' => 'bounded-server-'.$id, 'private_key_id' => $key->id, 'team_id' => 1, + ])); + } + } + $queries = 0; + (new PrivateKey)->getConnection()->listen(function () use (&$queries) { + $queries++; + }); + + app(CleanupUnusedSshKeys::class)->handle(); + + expect($queries)->toBeLessThanOrEqual(6); + $remaining = array_values(array_filter(Storage::disk('ssh-keys')->files(), fn ($file) => ! str_ends_with($file, '.lock'))); + expect($remaining)->toHaveCount(20); +}); + +it('cleans up 10000 local key files with mixed usage', function () { + $encryptedKey = encrypt(str_repeat('test-key-material', 240)); + for ($batch = 0; $batch < 20; $batch++) { + $keys = []; + $servers = []; + for ($offset = 1; $offset <= 500; $offset++) { + $id = $batch * 500 + $offset; + $keys[] = [ + 'id' => $id, 'uuid' => 'load-'.$id, 'name' => 'Load test '.$id, + 'private_key' => $encryptedKey, 'team_id' => 1, + ]; + unusedSshCleanupFile('load-'.$id); + if ($id <= 5000) { + $servers[] = [ + 'uuid' => 'load-server-'.$id, 'name' => 'Load server '.$id, + 'ip' => '127.0.0.1', 'private_key_id' => $id, 'team_id' => 1, + ]; + } + } + PrivateKey::query()->insert($keys); + if ($servers !== []) { + Server::query()->insert($servers); + } + } + + $queries = 0; + $cacheReads = 0; + $connection = (new PrivateKey)->getConnection(); + $connection->listen(function () use (&$queries) { + $queries++; + }); + app('events')->listen(CacheMissed::class, function () use (&$cacheReads) { + $cacheReads++; + }); + $memoryBefore = memory_get_usage(true); + memory_reset_peak_usage(); + $started = hrtime(true); + + app(CleanupUnusedSshKeys::class)->handle(); + + $seconds = (hrtime(true) - $started) / 1e9; + $peakExtraMiB = (memory_get_peak_usage(true) - $memoryBefore) / 1048576; + $jobQueries = $queries; + $files = Storage::disk('ssh-keys')->files(); + $remaining = array_values(array_filter($files, fn ($file) => ! str_ends_with($file, '.lock'))); + expect($remaining)->toHaveCount(5000); + foreach (range(1, 10000) as $id) { + expect(in_array('ssh_key@load-'.$id, $remaining, true))->toBe($id <= 5000); + } + expect(PrivateKey::query()->count())->toBe(10000); + fwrite(STDERR, sprintf( + "\nSSH cleanup load test: %.3f s, %.1f MiB extra peak, %d SQL queries, %d cache misses, 5000 deleted, 5000 retained, %d lock files\n", + $seconds, $peakExtraMiB, $jobQueries, $cacheReads, count($files) - count($remaining), + )); +})->skip(fn () => getenv('SSH_KEY_CLEANUP_LOAD_TEST') !== '1', 'Opt-in load test'); diff --git a/tests/Feature/ContainerFilesystemServiceTest.php b/tests/Feature/ContainerFilesystemServiceTest.php index 24d221c064..ac7614d714 100644 --- a/tests/Feature/ContainerFilesystemServiceTest.php +++ b/tests/Feature/ContainerFilesystemServiceTest.php @@ -32,11 +32,15 @@ function fsServer(): Server $team = Team::factory()->create(); $privateKey = PrivateKey::factory()->create(['team_id' => $team->id]); - return Server::factory()->create([ + $server = Server::factory()->create([ 'team_id' => $team->id, 'private_key_id' => $privateKey->id, 'ip' => '203.0.113.10', ]); + // These tests read the faked ssh commands in order; the host key check would add a key scan. + $server->settings->forceFill(['is_ssh_host_key_check_disabled' => true])->save(); + + return $server; } it('sorts directories before files, then by name case-insensitively', function () { diff --git a/tests/Feature/CrossTeamDestinationAttachTest.php b/tests/Feature/CrossTeamDestinationAttachTest.php index 891d87e494..0df036fe58 100644 --- a/tests/Feature/CrossTeamDestinationAttachTest.php +++ b/tests/Feature/CrossTeamDestinationAttachTest.php @@ -1,5 +1,6 @@ 'coolify-b-'.fake()->unique()->word(), ]); + foreach ([$this->serverA, $this->serverA2, $this->serverB] as $server) { + $server->settings->update(['is_reachable' => true, 'is_usable' => true]); + } + // Act as attacker (Team A) $this->actingAs($this->userA); session(['currentTeam' => $this->teamA]); @@ -258,6 +263,82 @@ describe('Destination::removeServer', function () { }); }); +describe('Destination on a server that is not usable', function () { + test('redeploy shows an error and queues no deployment when the server is not reachable', function (array $settings) { + $this->serverA2->settings->update($settings); + Server::flushIdentityMap(); + + Livewire::test(Destination::class, ['resource' => $this->applicationA]) + ->call('redeploy', $this->destinationA2->id, $this->serverA2->id) + ->assertDispatched('error', 'Failed to deploy.', "The server {$this->serverA2->name} is not reachable. Check the connection on the server page and try again.") + ->assertStatus(200); + + expect(ApplicationDeploymentQueue::query()->where('server_id', $this->serverA2->id)->exists())->toBeFalse(); + })->with([ + 'unreachable' => [['is_reachable' => false]], + 'unusable' => [['is_usable' => false]], + ]); + + test('addServer shows an error and attaches nothing when the server is not reachable', function () { + $this->serverA2->settings->update(['is_reachable' => false]); + Server::flushIdentityMap(); + + Livewire::test(Destination::class, ['resource' => $this->applicationA]) + ->call('addServer', $this->destinationA2->id, $this->serverA2->id) + ->assertDispatched('error', 'Failed to add server.', "The server {$this->serverA2->name} is not reachable. Check the connection on the server page and try again.") + ->assertStatus(200); + + expect($this->applicationA->fresh()->additional_networks)->toHaveCount(0); + }); + + test('promote shows an error and keeps the main destination when the server is not reachable', function () { + $this->applicationA->additional_networks()->attach($this->destinationA2->id, ['server_id' => $this->serverA2->id]); + $this->serverA2->settings->update(['is_reachable' => false]); + Server::flushIdentityMap(); + + Livewire::test(Destination::class, ['resource' => $this->applicationA]) + ->call('promote', $this->destinationA2->id, $this->serverA2->id) + ->assertDispatched('error', 'Failed to promote.', "The server {$this->serverA2->name} is not reachable. Check the connection on the server page and try again.") + ->assertStatus(200); + + expect($this->applicationA->fresh()->destination_id)->toBe($this->destinationA->id); + }); + + test('redeploy to a server of another team is still rejected without a toast', function () { + Livewire::test(Destination::class, ['resource' => $this->applicationA]) + ->call('redeploy', $this->destinationB->id, $this->serverB->id) + ->assertNotDispatched('error') + ->assertStatus(404); + + expect(ApplicationDeploymentQueue::query()->where('server_id', $this->serverB->id)->exists())->toBeFalse(); + }); +}); + +describe('Destination::stop', function () { + test('stops the application on an additional server that is no longer usable', function (array $settings) { + $this->applicationA->additional_networks()->attach($this->destinationA2->id, ['server_id' => $this->serverA2->id]); + $this->serverA2->settings->update($settings); + Server::flushIdentityMap(); + StopApplicationOneServer::shouldRun()->once() + ->withArgs(fn (Application $application, Server $server) => $application->is($this->applicationA) && $server->is($this->serverA2)); + + Livewire::test(Destination::class, ['resource' => $this->applicationA]) + ->call('stop', $this->serverA2->id) + ->assertHasNoErrors(); + })->with([ + 'unreachable' => [['is_reachable' => false]], + 'unusable' => [['is_usable' => false]], + 'force disabled' => [['force_disabled' => true]], + ]); + + test('cannot stop the application on a server of another team', function () { + StopApplicationOneServer::shouldNotRun(); + + Livewire::test(Destination::class, ['resource' => $this->applicationA]) + ->call('stop', $this->serverB->id); + }); +}); + describe('Destination after the user switches the session team', function () { beforeEach(function () { Server::flushIdentityMap(); diff --git a/tests/Feature/DatabaseImportFormFileCheckAuthorizationTest.php b/tests/Feature/DatabaseImportFormFileCheckAuthorizationTest.php index a5de664ee1..fa46b16ab2 100644 --- a/tests/Feature/DatabaseImportFormFileCheckAuthorizationTest.php +++ b/tests/Feature/DatabaseImportFormFileCheckAuthorizationTest.php @@ -1,9 +1,11 @@ call('checkFile') ->assertDispatched('error', 'Invalid file path. Path must be absolute and contain only safe characters (alphanumerics, dots, dashes, underscores, slashes).') ->call('checkS3File') - ->assertDispatched('error', 'Please select an S3 storage.'); + ->assertDispatched('error', 'Please select a storage.'); })->with(['admin', 'owner']); it('does nothing when an admin checks an empty server path', function () { @@ -102,3 +105,32 @@ it('does nothing when an admin checks an empty server path', function () { ->assertNotDispatched('error') ->assertSet('filename', null); }); + +it('does not check a file on an SFTP storage without a saved host key', function () { + $storage = S3Storage::create([ + 'team_id' => $this->team->id, + 'type' => BackupStorageType::Sftp, + 'name' => 'SFTP backups', + 'region' => 'us-east-1', + 'key' => 'backup', + 'secret' => 'password', + 'endpoint' => 'sftp://1.1.1.1:2222/backups', + 'is_usable' => true, + ]); + $connected = false; + app()->bind(SftpConnectionProvider::class, function () use (&$connected) { + $connected = true; + + throw new RuntimeException('Connected.'); + }); + + importFormFileCheckAs($this->team, 'owner') + ->set('s3StorageId', $storage->id) + ->set('s3Path', 'backups/db.dmp') + ->call('checkS3File') + ->assertDispatched('error') + ->assertSet('s3FileSize', null); + + expect($connected)->toBeFalse() + ->and($storage->fresh()->sftp_host_key)->toBeNull(); +}); diff --git a/tests/Feature/DeploymentGitKnownHostsTest.php b/tests/Feature/DeploymentGitKnownHostsTest.php new file mode 100644 index 0000000000..12976a2f83 --- /dev/null +++ b/tests/Feature/DeploymentGitKnownHostsTest.php @@ -0,0 +1,130 @@ +create(); + $project = Project::factory()->create(['team_id' => $team->id]); + $environment = $project->environments()->first(); + + return Application::withoutEvents(fn () => Application::forceCreate(['environment_id' => $environment->id, 'uuid' => 'host-keys-test', 'git_repository' => 'git@example.com:repo', 'git_branch' => 'main', 'build_pack' => 'dockerfile', 'name' => 'host keys', 'git_ssh_known_hosts' => $knownHosts])); +} + +function knownHostsTestQueue(Application $application, string $status = 'in_progress'): ApplicationDeploymentQueue +{ + return ApplicationDeploymentQueue::forceCreate(['application_id' => $application->id, 'deployment_uuid' => 'test', 'status' => $status, 'logs' => '[]']); +} + +/** + * A job for check_git_if_build_needed(): the application returns no git commands, and $run handles each remote command. + */ +function knownHostsTestCheckGitJob(Application $stored, ApplicationDeploymentQueue $queue, Closure $run): ApplicationDeploymentJob +{ + $job = new class($run) extends ApplicationDeploymentJob + { + public function __construct(public Closure $run) {} + + public function execute_remote_command(...$commands) + { + ($this->run)($commands, (new ReflectionProperty(ApplicationDeploymentJob::class, 'saved_outputs'))->getValue($this)); + } + }; + $application = new class extends Application + { + public function generateGitImportCommands(string $deployment_uuid, int $pull_request_id = 0, ?string $git_type = null, bool $exec_in_docker = true, bool $only_checkout = false, ?string $custom_base_dir = null, ?string $commit = null) + { + return ['commands' => collect(), 'branch' => 'main', 'fullRepoUrl' => 'git@example.com:repo']; + } + }; + $application->setRawAttributes($stored->getAttributes(), true); + $application->exists = true; + $application->setRelation('settings', (object) ['include_source_commit_in_build' => false, 'use_build_secrets' => false]); + $application->setRelation('private_key', null); + foreach (['application' => $application, 'application_deployment_queue' => $queue, 'deployment_uuid' => 'test', 'pull_request_id' => 0, 'commit' => 'HEAD', 'rollback' => false, 'git_type' => 'other', 'branch' => 'main', 'saved_outputs' => collect()] as $name => $value) { + (new ReflectionProperty(ApplicationDeploymentJob::class, $name))->setValue($job, $value); + } + + return $job; +} + +it('merges accepted keys with current storage without restoring removed seeds', function (string $current, string $expected, ?string $expectedLog) { + $seed = 'seed.example ssh-ed25519 AAAA'; + $accepted = 'new.example ssh-ed25519 BBBB'; + $application = knownHostsTestApplication($seed); + Application::query()->whereKey($application->id)->update(['git_ssh_known_hosts' => $current]); + $logs = []; + $queue = Mockery::mock(ApplicationDeploymentQueue::class)->makePartial(); + $queue->shouldReceive('addLogEntry')->zeroOrMoreTimes()->andReturnUsing(function (string $message, string $type = 'stdout') use (&$logs) { + $logs[] = [$type, $message]; + }); + $job = Mockery::mock(ApplicationDeploymentJob::class)->makePartial(); + $outputs = collect(); + foreach (['application' => $application, 'application_deployment_queue' => $queue, 'deployment_uuid' => 'test', 'saved_outputs' => $outputs] as $name => $value) { + (new ReflectionProperty(ApplicationDeploymentJob::class, $name))->setValue($job, $value); + } + $job->shouldReceive('execute_remote_command')->once()->andReturnUsing(function () use ($outputs, $seed, $accepted) { + $outputs->put('git_ssh_known_hosts', "$seed\n$accepted"); + }); + + (new ReflectionMethod(ApplicationDeploymentJob::class, 'save_git_ssh_known_hosts'))->invoke($job); + + expect($application->fresh()->git_ssh_known_hosts)->toBe($expected); + expect($application->isDirty('git_ssh_known_hosts'))->toBeFalse(); + expect(collect($logs)->where(0, 'stderr')->pluck(1)->all())->toBe($expectedLog === null ? [] : [$expectedLog]); +})->with([ + 'concurrent addition' => ['other.example ssh-ed25519 CCCC', "other.example ssh-ed25519 CCCC\nnew.example ssh-ed25519 BBBB", null], + 'already accepted' => ['new.example ssh-ed25519 BBBB', 'new.example ssh-ed25519 BBBB', null], + 'concurrent different key of the same type' => ['new.example ssh-ed25519 CCCC', 'new.example ssh-ed25519 CCCC', 'Coolify did not save the SSH host key of new.example (ssh-ed25519). Another deployment saved a different key of this type at the same time. Later deployments accept only the saved key.'], + 'concurrent key of another type' => ['new.example ecdsa-sha2-nistp256 DDDD', "new.example ecdsa-sha2-nistp256 DDDD\nnew.example ssh-ed25519 BBBB", null], +]); + +it('saves the host keys that ls-remote accepted, also when the build is skipped later', function () { + $application = knownHostsTestApplication(); + $queue = knownHostsTestQueue($application); + $job = knownHostsTestCheckGitJob($application, $queue, function (array $commands, $outputs) { + if (str_contains($commands[0]['command'] ?? $commands[0][0], 'cat /root/.ssh/coolify_git_test.known_hosts')) { + $outputs->put('git_ssh_known_hosts', "seed.example ssh-ed25519 AAAA\nnew.example ssh-ed25519 BBBB"); + } + }); + + (new ReflectionMethod(ApplicationDeploymentJob::class, 'check_git_if_build_needed'))->invoke($job); + + expect($application->fresh()->git_ssh_known_hosts)->toBe("seed.example ssh-ed25519 AAAA\nnew.example ssh-ed25519 BBBB"); +}); + +it('logs the host key hint when a git command of Coolify fails with a host key error', function () { + $application = knownHostsTestApplication(); + $queue = knownHostsTestQueue($application); + $job = knownHostsTestCheckGitJob($application, $queue, function () { + throw new DeploymentException("Command execution failed (exit code 128): git ls-remote\nError: Host key verification failed.\nfatal: Could not read from remote repository.", 128); + }); + + expect(fn () => (new ReflectionMethod(ApplicationDeploymentJob::class, 'check_git_if_build_needed'))->invoke($job))->toThrow(DeploymentException::class); + + expect($queue->fresh()->logs)->toContain(ApplicationDeploymentJob::GIT_HOST_KEY_CHANGED_HINT); +}); + +it('shows the host key callout only when the git step of Coolify failed with a host key error', function (?string $log, string $status, bool $shown) { + $application = knownHostsTestApplication(); + $queue = knownHostsTestQueue($application, $status); + $queue->addLogEntry($log ?? ApplicationDeploymentJob::GIT_HOST_KEY_CHANGED_HINT, 'stderr'); + $component = new Show; + $component->application = $application; + $component->application_deployment_queue = $queue->fresh(); + + expect($component->getHostKeyChangedSourceUrlProperty() !== null)->toBe($shown); +})->with([ + 'hint from the git step' => [null, 'failed', true], + 'ssh error from a Dockerfile RUN step' => ['Deployment failed: RUN git clone git@example.com:repo: Host key verification failed.', 'failed', false], + 'deployment still running' => [null, 'in_progress', false], +]); diff --git a/tests/Feature/DnsValidationTest.php b/tests/Feature/DnsValidationTest.php index 60e35439ac..64382dc061 100644 --- a/tests/Feature/DnsValidationTest.php +++ b/tests/Feature/DnsValidationTest.php @@ -6,11 +6,10 @@ use App\Models\InstanceSettings; use App\Models\IntegrationToken; use App\Models\Server; use App\Models\Team; +use App\Services\Dns\DnsRecord; +use App\Services\Dns\DnsResolver; use Illuminate\Foundation\Testing\RefreshDatabase; use Illuminate\Support\Facades\Http; -use PurplePixie\PhpDns\DNSQuery; -use PurplePixie\PhpDns\DNSResult; -use PurplePixie\PhpDns\DNSTypes; uses(RefreshDatabase::class); @@ -47,8 +46,8 @@ it('stops querying DNS servers after finding a matching IP', function (string $r $queriedServers = new ArrayObject; $targetIp = '203.0.113.10'; - app()->bind(DNSQuery::class, function ($app, array $parameters) use ($queriedServers, $resolvedIp) { - return new class($parameters['server'], $queriedServers, $resolvedIp) extends DNSQuery + app()->bind(DnsResolver::class, function ($app, array $parameters) use ($queriedServers, $resolvedIp) { + return new class($parameters['server'], $queriedServers, $resolvedIp) extends DnsResolver { public function __construct( private readonly string $dnsServer, @@ -58,11 +57,11 @@ it('stops querying DNS servers after finding a matching IP', function (string $r parent::__construct($dnsServer); } - public function query(string $question, string $typeName = DNSTypes::NAME_A) + public function query(string $question, string $typeName = DnsResolver::TYPE_A): array|false { $this->queriedServers->append($this->dnsServer); - return [new DNSResult($typeName, 1, 'IN', 60, $this->resolvedIp, $question, '', [])]; + return [new DnsRecord($typeName, $this->resolvedIp)]; } public function hasError(): bool @@ -97,10 +96,10 @@ it('does not start another resolver query after the total dns budget is exhauste )); $queryCount = new ArrayObject; - app()->bind(DNSQuery::class, function () use ($queryCount) { + app()->bind(DnsResolver::class, function () use ($queryCount) { $queryCount->append(true); - return new DNSQuery('192.0.2.1'); + return new DnsResolver('192.0.2.1'); }); $result = CheckDomainDns::run( @@ -124,9 +123,9 @@ it('falls back to the system resolver when custom dns servers return no answers' ] )); - app()->bind(DNSQuery::class, fn () => new class('192.0.2.1') extends DNSQuery + app()->bind(DnsResolver::class, fn () => new class('192.0.2.1') extends DnsResolver { - public function query(string $question, string $typeName = DNSTypes::NAME_A): false + public function query(string $question, string $typeName = DnsResolver::TYPE_A): false { return false; } @@ -163,11 +162,11 @@ it('does not use the system resolver after a custom dns server returns an addres ] )); - app()->bind(DNSQuery::class, fn () => new class('192.0.2.1') extends DNSQuery + app()->bind(DnsResolver::class, fn () => new class('192.0.2.1') extends DnsResolver { - public function query(string $question, string $typeName = DNSTypes::NAME_A): array + public function query(string $question, string $typeName = DnsResolver::TYPE_A): array { - return [new DNSResult($typeName, 1, 'IN', 60, '198.51.100.20', $question, '', [])]; + return [new DnsRecord($typeName, '198.51.100.20')]; } public function hasError(): bool @@ -199,16 +198,16 @@ it('treats equivalent IPv6 notations as the same address', function (string $res ['is_dns_validation_enabled' => true, 'custom_dns_servers' => '192.0.2.1'], )); - app()->bind(DNSQuery::class, fn () => new class('192.0.2.1', $resolvedIp) extends DNSQuery + app()->bind(DnsResolver::class, fn () => new class('192.0.2.1', $resolvedIp) extends DnsResolver { public function __construct(string $server, private readonly string $resolvedIp) { parent::__construct($server); } - public function query(string $question, string $typeName = DNSTypes::NAME_A) + public function query(string $question, string $typeName = DnsResolver::TYPE_A): array|false { - return [new DNSResult($typeName, 1, 'IN', 60, $this->resolvedIp, $question, '', [])]; + return [new DnsRecord($typeName, $this->resolvedIp)]; } public function hasError(): bool @@ -253,11 +252,11 @@ it('validateDNSEntry treats equivalent IPv6 notations as the same address', func ['is_dns_validation_enabled' => true, 'custom_dns_servers' => '192.0.2.1'], )); - app()->bind(DNSQuery::class, fn () => new class('192.0.2.1') extends DNSQuery + app()->bind(DnsResolver::class, fn () => new class('192.0.2.1') extends DnsResolver { - public function query(string $question, string $typeName = DNSTypes::NAME_A) + public function query(string $question, string $typeName = DnsResolver::TYPE_A): array|false { - return [new DNSResult($typeName, 1, 'IN', 60, '2001:db8::1', $question, '', [])]; + return [new DnsRecord($typeName, '2001:db8::1')]; } public function hasError(): bool @@ -282,12 +281,12 @@ it('passes bracketed IPv6 custom dns servers to the dns query', function () { )); $queriedServers = new ArrayObject; - app()->bind(DNSQuery::class, function ($app, array $parameters) use ($queriedServers) { + app()->bind(DnsResolver::class, function ($app, array $parameters) use ($queriedServers) { $queriedServers->append($parameters['server']); - return new class('192.0.2.1') extends DNSQuery + return new class('192.0.2.1') extends DnsResolver { - public function query(string $question, string $typeName = DNSTypes::NAME_A): false + public function query(string $question, string $typeName = DnsResolver::TYPE_A): false { return false; } @@ -317,11 +316,11 @@ it('checks the Cloudflare record origin when a proxied hostname is in a zone of ['id' => 0], ['is_dns_validation_enabled' => true, 'custom_dns_servers' => '192.0.2.1'] )); - app()->bind(DNSQuery::class, fn () => new class('192.0.2.1') extends DNSQuery + app()->bind(DnsResolver::class, fn () => new class('192.0.2.1') extends DnsResolver { - public function query(string $question, string $typeName = DNSTypes::NAME_A) + public function query(string $question, string $typeName = DnsResolver::TYPE_A): array|false { - return [new DNSResult($typeName, 1, 'IN', 60, '104.16.0.1', $question, '', [])]; + return [new DnsRecord($typeName, '104.16.0.1')]; } public function hasError(): bool @@ -367,11 +366,11 @@ it('keeps a Cloudflare IP as a match when the Cloudflare records cannot be read' ['id' => 0], ['is_dns_validation_enabled' => true, 'custom_dns_servers' => '192.0.2.1'] )); - app()->bind(DNSQuery::class, fn () => new class('192.0.2.1') extends DNSQuery + app()->bind(DnsResolver::class, fn () => new class('192.0.2.1') extends DnsResolver { - public function query(string $question, string $typeName = DNSTypes::NAME_A) + public function query(string $question, string $typeName = DnsResolver::TYPE_A): array|false { - return [new DNSResult($typeName, 1, 'IN', 60, '104.16.0.1', $question, '', [])]; + return [new DnsRecord($typeName, '104.16.0.1')]; } public function hasError(): bool diff --git a/tests/Feature/EmailChannelSmtpHostTest.php b/tests/Feature/EmailChannelSmtpHostTest.php new file mode 100644 index 0000000000..5a98b494ca --- /dev/null +++ b/tests/Feature/EmailChannelSmtpHostTest.php @@ -0,0 +1,79 @@ + 0]); + config(['constants.coolify.self_hosted' => true]); + + $this->team = Team::factory()->create(); + $user = User::factory()->create(['email' => 'member@example.com']); + $this->team->members()->attach($user->id, ['role' => 'owner']); + + $this->team->emailNotificationSettings->update([ + 'use_instance_email_settings' => false, + 'resend_enabled' => false, + 'smtp_enabled' => true, + 'smtp_from_name' => 'Coolify', + 'smtp_from_address' => 'sender@example.com', + 'smtp_port' => 25, + 'smtp_timeout' => 1, + ]); + + $this->smtpNotification = new class extends Notification + { + public string $emails = 'member@example.com'; + + public function toMail(object $notifiable): MailMessage + { + return (new MailMessage)->subject('Deployment')->line('Deployed'); + } + }; +}); + +afterEach(function () { + SafeWebhookUrl::resolveHostsUsing(null); +}); + +it('counts a blocked SMTP host as a delivery failure', function () { + $this->team->emailNotificationSettings->update(['smtp_host' => '127.0.0.1']); + + (new EmailChannel)->send($this->team->fresh(), $this->smtpNotification); + + expect($this->team->emailNotificationSettings()->first()->failure_count)->toBe(1); +}); + +it('retries an SMTP host that does not resolve without counting a delivery failure', function () { + SafeWebhookUrl::resolveHostsUsing(fn (): array => []); + $this->team->emailNotificationSettings->update(['smtp_host' => 'smtp.example.com']); + + expect(fn () => (new EmailChannel)->send($this->team->fresh(), $this->smtpNotification)) + ->toThrow(RuntimeException::class, 'The SMTP host could not be resolved.'); + + expect($this->team->emailNotificationSettings()->first()->failure_count)->toBe(0); +}); + +it('sends to stored hosts with underscores, a trailing dot, or upper case', function (string $storedHost) { + InstanceSettings::query()->whereKey(0)->update([ + 'webhook_allow_localhost' => true, + 'webhook_allowed_internal_hosts' => ['127.0.0.1'], + ]); + SafeWebhookUrl::resolveHostsUsing(fn (string $host): array => in_array($host, ['mail_server', 'smtp.example.com'], true) ? ['127.0.0.1'] : []); + // Nothing listens on port 1, so the connection to the checked address is refused at once. + $this->team->emailNotificationSettings->update(['smtp_host' => $storedHost, 'smtp_port' => 1, 'smtp_encryption' => 'none']); + + expect(fn () => (new EmailChannel)->send($this->team->fresh(), $this->smtpNotification)) + ->toThrow(TransportException::class, '127.0.0.1:1'); + + expect($this->team->emailNotificationSettings()->first()->failure_count)->toBe(0); +})->with(['mail_server', 'smtp.example.com.', 'SMTP.Example.COM']); diff --git a/tests/Feature/EmailNotificationSmtpHostTest.php b/tests/Feature/EmailNotificationSmtpHostTest.php new file mode 100644 index 0000000000..708511d32b --- /dev/null +++ b/tests/Feature/EmailNotificationSmtpHostTest.php @@ -0,0 +1,39 @@ + 0]); + config(['constants.coolify.self_hosted' => true]); + SafeWebhookUrl::resolveHostsUsing(fn (string $host): array => $host === 'smtp.example.com' ? ['93.184.216.34'] : []); + + $this->team = Team::factory()->create(); + $this->instanceAdmin = User::factory()->create(); + $this->instanceAdmin->teams()->attach(Team::factory()->create(['id' => 0]), ['role' => 'admin']); + $this->instanceAdmin->teams()->attach($this->team, ['role' => 'admin']); + + $this->actingAs($this->instanceAdmin); + session(['currentTeam' => $this->team]); +}); + +afterEach(function () { + SafeWebhookUrl::resolveHostsUsing(null); +}); + +it('checks a changed SMTP host when the whole form is saved', function () { + Livewire::test(EmailNotification::class) + ->set('smtpHost', '127.0.0.1') + ->call('submit') + ->assertDispatched('error') + ->assertNotDispatched('success'); + + expect($this->team->emailNotificationSettings()->first()->smtp_host)->toBeNull(); +}); diff --git a/tests/Feature/EmailProviderSelectionTest.php b/tests/Feature/EmailProviderSelectionTest.php new file mode 100644 index 0000000000..6d0a11f93a --- /dev/null +++ b/tests/Feature/EmailProviderSelectionTest.php @@ -0,0 +1,121 @@ + true, + 'smtp_host' => 'smtp.example.com', + 'smtp_port' => '587', + 'smtp_encryption' => 'starttls', + 'smtp_from_address' => 'alerts@example.com', + 'smtp_from_name' => 'Coolify', + 'resend_enabled' => false, + 'resend_api_key' => null, + ]; +} + +beforeEach(function () { + $this->rootTeam = Team::find(0) ?? Team::factory()->create(['id' => 0]); + InstanceSettings::forceCreate(['id' => 0, ...workingSmtpSettings()]); + Once::flush(); +}); + +test('an unsaved resend selection is not saved by a later team notification event toggle', function () { + $team = Team::factory()->create(); + $user = User::factory()->create(); + $team->members()->attach($user->id, ['role' => 'admin']); + $team->emailNotificationSettings->update([...workingSmtpSettings(), 'use_instance_email_settings' => false]); + + $this->actingAs($user); + session(['currentTeam' => $team]); + + Livewire::test(NotificationEmail::class) + ->set('emailProvider', 'resend') + ->call('selectEmailProvider') + ->assertSet('emailProvider', 'resend') + ->call('toggleEvent', 'deploymentSuccessEmailNotifications') + ->call('instantSaveDeploymentCommitDetails'); + + $settings = $team->emailNotificationSettings->fresh(); + + expect($settings->smtp_enabled)->toBeTrue() + ->and($settings->resend_enabled)->toBeFalse() + ->and($settings->deployment_success_email_notifications)->toBeTrue(); +}); + +test('an unsaved resend selection is not saved by a later instance provider toggle', function () { + $user = User::factory()->create(); + $this->rootTeam->members()->attach($user->id, ['role' => 'admin']); + + $this->actingAs($user); + session(['currentTeam' => ['id' => 0]]); + + Livewire::test(SettingsEmail::class) + ->set('emailProvider', 'resend') + ->call('selectEmailProvider') + ->call('toggleResend'); + + $settings = InstanceSettings::find(0); + + expect($settings->smtp_enabled)->toBeTrue() + ->and($settings->resend_enabled)->toBeFalse(); +}); + +test('a completed resend selection is saved on submit', function () { + $team = Team::factory()->create(); + $user = User::factory()->create(); + $team->members()->attach($user->id, ['role' => 'admin']); + $team->emailNotificationSettings->update([...workingSmtpSettings(), 'use_instance_email_settings' => false]); + + $this->actingAs($user); + session(['currentTeam' => $team]); + + Livewire::test(NotificationEmail::class) + ->set('emailProvider', 'resend') + ->call('selectEmailProvider') + ->set('resendApiKey', 're_test_key') + ->set('resendFromAddress', 'resend@example.com') + ->set('resendFromName', 'Coolify') + ->call('submit') + ->assertHasNoErrors(); + + $settings = $team->emailNotificationSettings->fresh(); + + expect($settings->resend_enabled)->toBeTrue() + ->and($settings->smtp_enabled)->toBeFalse() + ->and($settings->resend_from_address)->toBe('resend@example.com'); +}); + +test('turning team resend off does not require a resend sender', function () { + $team = Team::factory()->create(); + $user = User::factory()->create(); + $team->members()->attach($user->id, ['role' => 'admin']); + $team->emailNotificationSettings->update([ + 'use_instance_email_settings' => false, + 'resend_enabled' => true, + 'resend_api_key' => 're_test_key', + 'smtp_from_address' => 'alerts@example.com', + ]); + + $this->actingAs($user); + session(['currentTeam' => $team]); + + Livewire::test(NotificationEmail::class) + ->set('resendEnabled', false) + ->call('submitResend') + ->assertHasNoErrors() + ->assertNotDispatched('error'); + + expect($team->emailNotificationSettings->fresh()->resend_enabled)->toBeFalse(); +}); diff --git a/tests/Feature/EnvironmentCanvasTest.php b/tests/Feature/EnvironmentCanvasTest.php index d424ee22b6..9a8dd10df7 100644 --- a/tests/Feature/EnvironmentCanvasTest.php +++ b/tests/Feature/EnvironmentCanvasTest.php @@ -136,6 +136,7 @@ describe('GET canvas', function () { 'link' => $application->link(), 'logo' => ['src' => resource_logo_url('public-repo'), 'dark' => null, 'fallback' => null, 'padded' => false, 'favicon' => 'https://web.example.com/favicon.ico'], 'saved' => ['x' => 10, 'y' => -20], + 'security' => null, ]) ->and($nodes[$service->uuid])->toEqual([ 'id' => $service->uuid, @@ -154,6 +155,7 @@ describe('GET canvas', function () { 'link' => $service->link(), 'logo' => null, 'saved' => null, + 'security' => null, ]) ->and($nodes[$database->uuid])->toMatchArray([ 'type' => 'database', diff --git a/tests/Feature/GiteaSourceChangeViewTest.php b/tests/Feature/GiteaSourceChangeViewTest.php index 0fc92ea783..eea522b93a 100644 --- a/tests/Feature/GiteaSourceChangeViewTest.php +++ b/tests/Feature/GiteaSourceChangeViewTest.php @@ -48,7 +48,7 @@ describe('Gitea source setup view', function () { test('shows incomplete-setup guidance with the credential form', function () { Livewire::withQueryParams(['gitea_app_uuid' => $this->giteaApp->uuid]) ->test(Change::class) - ->assertSee('Finish connecting this Gitea App before using it as a source') + ->assertSee('Finish connecting this Gitea / Forgejo App before using it as a source') ->assertSee('https://gitea.com/user/settings/applications') ->assertSee('Client ID') ->assertSee('Client secret') diff --git a/tests/Feature/GiteaSourceListTest.php b/tests/Feature/GiteaSourceListTest.php index 879d928095..4fee67f288 100644 --- a/tests/Feature/GiteaSourceListTest.php +++ b/tests/Feature/GiteaSourceListTest.php @@ -36,7 +36,7 @@ test('sources page lists gitea apps with a link to their settings', function () $this->get(route('source.all')) ->assertOk() - ->assertSee('New Gitea App') + ->assertSee('New Gitea / Forgejo App') ->assertSee('Team Gitea') ->assertSee('Gitea · acme') ->assertSee(route('source.gitea.show', ['gitea_app_uuid' => $this->giteaApp->uuid]), false); diff --git a/tests/Feature/GithubRunnerSettingsTest.php b/tests/Feature/GithubRunnerSettingsTest.php index a5c27b098f..d5834dfddc 100644 --- a/tests/Feature/GithubRunnerSettingsTest.php +++ b/tests/Feature/GithubRunnerSettingsTest.php @@ -627,6 +627,7 @@ describe('GitHub App runner permissions', function () { 'permissions' => ['contents' => 'read', 'metadata' => 'read', 'organization_self_hosted_runners' => 'write', 'actions' => 'read'], 'events' => ['push', 'workflow_job'], ]), + "https://api.github.com/app/installations/{$this->githubApp->installation_id}" => Http::response(['permissions' => []]), ]); GithubAppPermissionJob::dispatchSync($this->githubApp); diff --git a/tests/Feature/GlobalSearchNavigationTest.php b/tests/Feature/GlobalSearchNavigationTest.php index 99c2d5ab79..e62aac8a58 100644 --- a/tests/Feature/GlobalSearchNavigationTest.php +++ b/tests/Feature/GlobalSearchNavigationTest.php @@ -51,6 +51,7 @@ it('lists every instance settings page for the root team', function () { route('settings.backup'), route('settings.email'), route('settings.oauth'), + route('settings.image-scanning'), ); }); @@ -74,6 +75,7 @@ it('lists team, security, notification and profile pages for a team owner', func route('notifications.pushover'), route('notifications.webhook'), route('profile.appearance'), + route('team.image-scanning'), ...adminOnlyNavigationLinks(), ); }); diff --git a/tests/Feature/ImageScan/DeploymentGateTest.php b/tests/Feature/ImageScan/DeploymentGateTest.php new file mode 100644 index 0000000000..d57d43170f --- /dev/null +++ b/tests/Feature/ImageScan/DeploymentGateTest.php @@ -0,0 +1,1602 @@ + */ + public array $commands = []; + + /** @var array */ + public array $outputs = []; + + /** Stop at the container start, or let the job go on. */ + public bool $stopAtStart = true; + + public function __construct() {} + + public function execute_remote_command(...$commands): void + { + $saved = (new ReflectionProperty(ApplicationDeploymentJob::class, 'saved_outputs'))->getValue($this); + + foreach ($commands as $command) { + $text = (string) ($command['command'] ?? $command[0]); + $this->commands[] = $text; + + if ($this->stopAtStart && preg_match('/ up (--pull always )?--build -d|stack deploy/', $text) === 1) { + throw new DeploymentGateStarted('started'); + } + + $output = ''; + foreach ($this->outputs as $needle => $value) { + if (str_contains($text, $needle)) { + $output = $value; + break; + } + } + + if (($save = data_get($command, 'save')) !== null) { + $saved->put($save, str($output)); + } + } + } +} + +beforeEach(function () { + Once::flush(); + Server::flushIdentityMap(); + Storage::fake('ssh-keys'); + Process::fake(); + config(['constants.ssh.mux_enabled' => false, 'constants.ssh.max_retries' => 1]); + InstanceSettings::forceCreate(['id' => 0]); + $this->withoutDefer(); + + $this->team = Team::factory()->create(); + $this->server = deployGateServer($this->team, 'main-server'); + $this->destination = StandaloneDocker::query()->where('server_id', $this->server->id)->firstOrFail(); + $this->project = Project::factory()->create(['team_id' => $this->team->id]); + $this->environment = Environment::factory()->create(['project_id' => $this->project->id]); + $this->application = Application::factory()->create([ + 'environment_id' => $this->environment->id, + 'destination_id' => $this->destination->id, + 'destination_type' => $this->destination->getMorphClass(), + 'build_pack' => 'dockerfile', + ]); + $this->queue = ApplicationDeploymentQueue::create([ + 'deployment_uuid' => 'gate-deployment', + 'application_id' => $this->application->id, + 'server_id' => $this->server->id, + 'destination_id' => $this->destination->id, + 'status' => ApplicationDeploymentStatus::IN_PROGRESS->value, + 'commit' => GATE_COMMIT, + ]); +}); + +function deployGateServer(Team $team, string $name, bool $scanning = true): Server +{ + $server = Server::factory()->create([ + 'name' => $name, + 'team_id' => $team->id, + 'private_key_id' => (PrivateKey::query()->where('team_id', $team->id)->first() ?? PrivateKey::factory()->create(['team_id' => $team->id]))->id, + ]); + $server->settings->forceFill(['is_reachable' => true, 'is_usable' => true, 'is_image_scan_enabled' => $scanning])->saveQuietly(); + + return $server->fresh(); +} + +function deployGatePolicy(string $onScanError = 'deploy', string $mode = 'on'): void +{ + ImageScanPolicy::query()->create([ + 'scope_type' => test()->application->getMorphClass(), + 'scope_id' => test()->application->id, + 'gate_mode' => $mode, + 'on_scan_error' => $onScanError, + ]); +} + +/** + * @return array + */ +function deployGateFindingRow(string $severity = 'critical', bool $kev = true, string $id = 'CVE-2026-1234'): array +{ + return [ + 'id' => $id, 'aliases' => [], 'severity' => $severity, 'package' => 'openssl', 'version' => '3.0.13', + 'type' => 'npm', 'artifactId' => 'a-1', 'fixedIn' => '3.0.15', 'fixState' => 'fixed', 'kev' => $kev, + 'epss' => null, 'risk' => 1.0, 'summary' => '', 'layer' => 'app', + ]; +} + +/** + * @param list> $findings + */ +function deployGateScanAttributes(array $findings): array +{ + return [ + 'server_id' => test()->server->id, + 'resource_type' => test()->application->getMorphClass(), + 'resource_id' => test()->application->id, + 'image_id' => GATE_IMAGE_ID, + 'image_ref' => 'app:latest', + 'status' => ImageScanStatus::Completed, + 'builds_image' => true, + 'findings' => $findings, + 'summary' => ['counts' => ['fixable_now' => ['critical' => count($findings)], 'not_released' => [], 'no_fix' => []]], + ]; +} + +function deployGateStoredScan(array $findings, array $attributes = []): ImageScan +{ + return ImageScan::factory()->create([...deployGateScanAttributes($findings), ...$attributes]); +} + +function deployGateReturnedScan(array $findings): ImageScan +{ + return ImageScan::factory()->make(deployGateScanAttributes($findings)); +} + +/** + * @param array $overrides + */ +function deployGateJob(array $overrides = []): DeploymentGateTestJob +{ + $application = test()->application->fresh(); + $imageRef = $overrides['production_image_name'] ?? "{$application->uuid}:".GATE_COMMIT; + $compose = Yaml::dump(['services' => ['gate-app' => ['image' => $imageRef, 'container_name' => 'gate-app']]], 10); + + $job = new DeploymentGateTestJob; + $job->outputs = ['docker image inspect' => GATE_IMAGE_ID.' ']; + $reflection = new ReflectionClass(ApplicationDeploymentJob::class); + + foreach ([ + 'application' => $application, + 'application_deployment_queue' => test()->queue->fresh(), + 'server' => test()->server, + 'mainServer' => test()->server, + 'build_server' => test()->server, + 'use_build_server' => false, + 'is_this_additional_server' => false, + 'destination' => test()->destination, + 'deployment_uuid' => 'gate-deployment', + 'pull_request_id' => 0, + 'commit' => GATE_COMMIT, + 'rollback' => false, + 'restart_only' => false, + 'basedir' => '/artifacts/gate-deployment', + 'workdir' => '/artifacts/gate-deployment', + 'configuration_dir' => '/data/coolify/applications/gate', + 'container_name' => 'gate-app', + 'production_image_name' => $imageRef, + 'docker_compose' => $compose, + 'generated_docker_compose' => $compose, + 'docker_compose_location' => '/docker-compose.yaml', + 'coolify_variables' => '', + 'preserveRepository' => false, + 'saved_outputs' => new Collection, + ...$overrides, + ] as $property => $value) { + $reflection->getProperty($property)->setValue($job, $value); + } + + return $job; +} + +function deployGateCall(DeploymentGateTestJob $job, string $method, mixed ...$arguments): mixed +{ + return (new ReflectionMethod(ApplicationDeploymentJob::class, $method))->invoke($job, ...$arguments); +} + +function deployGateImageScan(DeploymentGateTestJob $job): DeploymentImageScan +{ + return deployGateCall($job, 'imageScan'); +} + +/** + * Runs the job until the container start. Returns true when the start was reached. + */ +function deployGateReachesStart(DeploymentGateTestJob $job, string $method = 'rolling_update'): bool +{ + try { + deployGateCall($job, $method); + } catch (Throwable $exception) { + for ($cause = $exception; $cause !== null; $cause = $cause->getPrevious()) { + if ($cause instanceof DeploymentGateStarted) { + return true; + } + } + + throw $exception; + } + + return false; +} + +function deployGateBlocked(DeploymentGateTestJob $job, string $method = 'rolling_update'): DeploymentException +{ + try { + deployGateReachesStart($job, $method); + } catch (DeploymentException $exception) { + return $exception; + } + + throw new RuntimeException('Expected the gate to block the deploy.'); +} + +function deployGateLogs(): string +{ + return collect(json_decode((string) test()->queue->fresh()->logs, true))->pluck('output')->implode("\n"); +} + +function deployGateStartCommand(DeploymentGateTestJob $job): string +{ + return (string) collect($job->commands)->last(); +} + +it('starts without a scan when the gate is off and scans after the deploy', function () { + ScanImage::shouldRun()->never(); + Queue::fake(); + $job = deployGateJob(); + + expect(deployGateReachesStart($job))->toBeTrue() + ->and(collect($job->commands)->contains(fn (string $command) => str_contains($command, 'docker image inspect') || str_contains($command, 'docker pull')))->toBeFalse() + ->and(test()->queue->fresh()->scan_gate_result)->toBeNull(); + + deployGateCall($job, 'post_deployment'); + + Queue::assertPushed(ScanResourceImagesJob::class, fn (ScanResourceImagesJob $scanJob) => $scanJob->resource->is(test()->application) + && $scanJob->deploymentQueueId === test()->queue->id + && $scanJob->imageRefs === [test()->application->uuid.':'.GATE_COMMIT] + && $scanJob->queue === 'default'); +}); + +it('stores the security status again when the deployment finished', function () { + Queue::fake(); + ApplicationDeploymentQueue::create([ + 'deployment_uuid' => 'previous-deployment', + 'application_id' => $this->application->id, + 'server_id' => $this->server->id, + 'status' => ApplicationDeploymentStatus::FINISHED->value, + 'commit' => 'fedcba9876543210fedcba9876543210fedcba98', + ]); + $this->queue = ApplicationDeploymentQueue::create([ + 'deployment_uuid' => 'gate-deployment-2', + 'application_id' => $this->application->id, + 'server_id' => $this->server->id, + 'destination_id' => $this->destination->id, + 'status' => ApplicationDeploymentStatus::IN_PROGRESS->value, + 'commit' => GATE_COMMIT, + ]); + $scan = ImageScan::factory()->create([ + 'server_id' => $this->server->id, + 'resource_type' => $this->application->getMorphClass(), + 'resource_id' => $this->application->id, + 'deployment_queue_id' => $this->queue->id, + 'image_ref' => $this->application->uuid.':'.GATE_COMMIT, + 'builds_image' => true, + 'findings' => [], + 'summary' => ['truncated' => false, 'eol_distro' => null], + 'grype_db_built_at' => now(), + ]); + ResourceScans::refreshSummaries(ResourceScans::currentFor($this->application)); + expect($scan->fresh()->summary['security']['check_reason'])->toBe('image_changed'); + + deployGateCall(deployGateJob(), 'post_deployment'); + + expect($this->queue->fresh()->status)->toBe(ApplicationDeploymentStatus::FINISHED->value) + ->and($scan->fresh()->summary['security']['status'])->toBe('ok'); +}); + +it('does not store the security status again when scanning is off for the app', function () { + Queue::fake(); + $this->application->settings->forceFill(['is_image_scan_enabled' => false])->saveQuietly(); + $scan = ImageScan::factory()->create([ + 'server_id' => $this->server->id, + 'resource_type' => $this->application->getMorphClass(), + 'resource_id' => $this->application->id, + 'deployment_queue_id' => $this->queue->id, + 'image_ref' => $this->application->uuid.':'.GATE_COMMIT, + 'builds_image' => true, + 'findings' => [], + 'summary' => ['truncated' => false, 'eol_distro' => null, 'security' => ['status' => 'check_needed', 'check_reason' => 'image_changed']], + 'grype_db_built_at' => now(), + ]); + $summaryQueries = 0; + DB::listen(function (QueryExecuted $query) use (&$summaryQueries) { + if (str_contains($query->sql, 'update "image_scans"')) { + $summaryQueries++; + } + }); + + deployGateCall(deployGateJob(), 'post_deployment'); + + expect($this->queue->fresh()->status)->toBe(ApplicationDeploymentStatus::FINISHED->value) + ->and($scan->fresh()->summary['security']['check_reason'])->toBe('image_changed') + ->and($summaryQueries)->toBe(0); +}); + +it('does not queue a scan after a skipped build or on an additional server when the gate is off', function (array $overrides) { + Queue::fake(); + + deployGateCall(deployGateJob($overrides), 'post_deployment'); + + Queue::assertNotPushed(ScanResourceImagesJob::class); +})->with([ + 'skipped build' => [['skip_build' => true]], + 'additional server' => [['is_this_additional_server' => true]], +]); + +it('queues a scan after a rollback that skipped the build', function () { + Queue::fake(); + + deployGateCall(deployGateJob(['skip_build' => true, 'rollback' => true]), 'post_deployment'); + + Queue::assertPushed(ScanResourceImagesJob::class); +}); + +it('does not queue a scan after the deploy when scanning is off on the server', function () { + Queue::fake(); + $this->server->settings->forceFill(['is_image_scan_enabled' => false])->saveQuietly(); + + deployGateCall(deployGateJob(['server' => $this->server->fresh(), 'mainServer' => $this->server->fresh()]), 'post_deployment'); + + Queue::assertNotPushed(ScanResourceImagesJob::class); +}); + +it('blocks the deploy when the image has a problem that attackers are using', function () { + deployGatePolicy(); + ScanImage::shouldRun()->once() + ->withArgs(fn ($server, $resource, $ref, $deployment, $wait) => $server->is(test()->server) && $resource->is(test()->application) + && $ref === test()->application->uuid.':'.GATE_COMMIT && $deployment->id === test()->queue->id && $wait === 120) + ->andReturn(deployGateReturnedScan([deployGateFindingRow()])); + $job = deployGateJob(); + + $exception = deployGateBlocked($job); + $job->failed($exception); + + $queue = test()->queue->fresh(); + expect($exception->getMessage())->toBe(DEPLOY_GATE_HEADLINE) + ->and($queue->status)->toBe(ApplicationDeploymentStatus::FAILED->value) + ->and($queue->scan_gate_result['blocked'])->toBeTrue() + ->and($queue->scan_gate_result['count'])->toBe(1) + ->and($queue->scan_gate_result['action'] ?? null)->toBe(['sentence' => 'Upgrade openssl to 3.0.15 or later, then redeploy.', 'button' => false]) + ->and(deployGateLogs())->toContain(DEPLOY_GATE_HEADLINE) + ->toContain('CVE-2026-1234 Critical openssl 3.0.13 used by attackers') + ->and(collect($job->commands)->contains(fn (string $command) => str_contains($command, ' up ')))->toBeFalse(); +}); + +it('starts the deploy when the scan finds no problem that attackers use', function () { + deployGatePolicy(); + ScanImage::shouldRun()->once()->andReturn(deployGateReturnedScan([])); + + expect(deployGateReachesStart(deployGateJob()))->toBeTrue() + ->and(test()->queue->fresh()->scan_gate_result)->toMatchArray(['blocked' => false, 'scan_ran' => true]); +}); + +it('reuses the stored scan of an image found on the server and blocks it', function () { + deployGatePolicy(); + ScanImage::shouldRun()->never(); + deployGateStoredScan([deployGateFindingRow()]); + $job = deployGateJob(['saved_outputs' => new Collection(['local_image_found' => str('sha256:aaaa')])]); + + expect(deployGateBlocked($job)->getMessage())->toBe(DEPLOY_GATE_HEADLINE) + ->and(test()->queue->fresh()->scan_gate_result['blocked'])->toBeTrue(); +}); + +it('reuses the scan of the main deployment on an additional server', function () { + deployGatePolicy(); + ScanImage::shouldRun()->never(); + deployGateStoredScan([deployGateFindingRow()]); + $additional = deployGateServer($this->team, 'additional-server'); + $job = deployGateJob(['is_this_additional_server' => true, 'server' => $additional, 'mainServer' => $additional]); + + expect(deployGateBlocked($job)->getMessage())->toBe(DEPLOY_GATE_HEADLINE); +}); + +it('keeps the gate commands intact for a non-root SSH user', function () { + deployGatePolicy(); + ScanImage::shouldRun()->once()->andReturn(deployGateReturnedScan([])); + $this->application->update(['build_pack' => 'dockerimage', 'docker_registry_image_name' => 'nginx', 'docker_registry_image_tag' => '1.27']); + $server = $this->server; + $server->forceFill(['user' => 'deploy'])->saveQuietly(); + $job = deployGateJob(['production_image_name' => 'nginx:1.27', 'server' => $server->fresh(), 'mainServer' => $server->fresh()]); + + deployGateReachesStart($job); + + $inspect = collect($job->commands)->first(fn (string $command) => str_contains($command, 'docker image inspect')); + $pull = collect($job->commands)->first(fn (string $command) => str_contains($command, 'docker pull')); + expect(parseLineForSudo($inspect, $server->fresh()))->toBe("sudo {$inspect}") + ->and(parseLineForSudo($pull, $server->fresh()))->toStartWith('sudo DOCKER_CONTEXT=default')->toEndWith("docker pull 'nginx:1.27'") + ->and($pull)->toStartWith('DOCKER_CONTEXT=default DOCKER_CONFIG='); +}); + +it('blocks a restart that skips the build with the stored scan', function () { + // Strict: in On mode the stored scan of the same image is also the baseline of an app that ran before. + deployGatePolicy(mode: 'strict'); + ScanImage::shouldRun()->never(); + deployGateStoredScan([deployGateFindingRow()]); + ApplicationDeploymentQueue::create([ + 'deployment_uuid' => 'gate-previous', + 'application_id' => $this->application->id, + 'server_id' => $this->server->id, + 'status' => ApplicationDeploymentStatus::FINISHED->value, + 'commit' => GATE_COMMIT, + 'configuration_snapshot' => $this->application->fresh()->deploymentConfigurationSnapshot(), + ]); + $job = deployGateJob([ + 'restart_only' => true, + 'saved_outputs' => new Collection(['local_image_found' => str('sha256:aaaa')]), + ]); + + expect(deployGateBlocked($job, 'should_skip_build')->getMessage())->toBe(DEPLOY_GATE_HEADLINE) + ->and(collect($job->commands)->contains(fn (string $command) => str_contains($command, ' up ')))->toBeFalse(); +}); + +function deployGatePreviousDeployment(string $uuid, ApplicationDeploymentStatus $status, string $commit = GATE_COMMIT, array $attributes = []): ApplicationDeploymentQueue +{ + return ApplicationDeploymentQueue::create([ + 'deployment_uuid' => $uuid, + 'application_id' => test()->application->id, + 'server_id' => test()->server->id, + 'status' => $status->value, + 'commit' => $commit, + ...$attributes, + ]); +} + +it('does not gate a rollback to a commit with a finished deployment', function () { + deployGatePolicy(onScanError: 'block'); + ScanImage::shouldRun()->never(); + deployGatePreviousDeployment('gate-earlier', ApplicationDeploymentStatus::FINISHED); + deployGateStoredScan([deployGateFindingRow()]); + $job = deployGateJob(['rollback' => true]); + + expect(deployGateReachesStart($job))->toBeTrue() + ->and(test()->queue->fresh()->scan_gate_result)->toBeNull() + ->and(deployGateLogs())->toContain('Rollback to an image that ran before'); +}); + +it('does not gate a rollback to an image that ran before scanning was turned on', function () { + deployGatePolicy(onScanError: 'block'); + ScanImage::shouldRun()->never(); + deployGatePreviousDeployment('gate-earlier', ApplicationDeploymentStatus::FINISHED); + $job = deployGateJob(['rollback' => true]); + + expect(deployGateReachesStart($job))->toBeTrue() + ->and(ImageScan::query()->count())->toBe(0) + ->and(test()->queue->fresh()->scan_gate_result)->toBeNull(); +}); + +it('gates a rollback to the commit of a blocked deployment', function () { + deployGatePolicy(); + ScanImage::shouldRun()->never(); + // The blocked image stays tagged on the server, so it is in the rollback list. That alone does not count. + GetRollbackImages::shouldNotRun(); + $blocked = deployGatePreviousDeployment('gate-blocked', ApplicationDeploymentStatus::FAILED, attributes: [ + 'scan_gate_result' => ['blocked' => true, 'scan_ran' => true, 'error' => null, 'count' => 1, 'headline' => DEPLOY_GATE_HEADLINE, 'top' => []], + ]); + deployGateStoredScan([deployGateFindingRow()], ['deployment_queue_id' => $blocked->id]); + $job = deployGateJob(['rollback' => true]); + + expect(deployGateBlocked($job)->getMessage())->toBe(DEPLOY_GATE_HEADLINE); +}); + +it('gates a rollback to a commit that a finished deployment of another application ran', function () { + deployGatePolicy(); + ScanImage::shouldRun()->once()->andReturn(deployGateReturnedScan([deployGateFindingRow()])); + $other = Application::factory()->create([ + 'environment_id' => $this->environment->id, + 'destination_id' => $this->destination->id, + 'destination_type' => $this->destination->getMorphClass(), + ]); + ApplicationDeploymentQueue::create([ + 'deployment_uuid' => 'gate-other-app', + 'application_id' => $other->id, + 'server_id' => $this->server->id, + 'status' => ApplicationDeploymentStatus::FINISHED->value, + 'commit' => GATE_COMMIT, + ]); + + expect(deployGateBlocked(deployGateJob(['rollback' => true]))->getMessage())->toBe(DEPLOY_GATE_HEADLINE); +}); + +it('does not gate a rollback to an image that a finished deployment scanned', function () { + deployGatePolicy(onScanError: 'block'); + ScanImage::shouldRun()->never(); + $finished = deployGatePreviousDeployment('gate-finished', ApplicationDeploymentStatus::FINISHED, commit: 'HEAD'); + deployGateStoredScan([deployGateFindingRow()], ['deployment_queue_id' => $finished->id]); + $job = deployGateJob(['rollback' => true]); + + expect(deployGateReachesStart($job))->toBeTrue() + ->and(test()->queue->fresh()->scan_gate_result)->toBeNull(); +}); + +it('gates a rollback to an image that never ran', function () { + deployGatePolicy(); + ScanImage::shouldRun()->once()->andReturn(deployGateReturnedScan([deployGateFindingRow()])); + $job = deployGateJob(['rollback' => true]); + + expect(deployGateBlocked($job)->getMessage())->toBe(DEPLOY_GATE_HEADLINE); +}); + +it('starts a deploy that a user deployed anyway and records it', function () { + deployGatePolicy(); + ScanImage::shouldRun()->never(); + $user = User::factory()->create(['name' => 'Ada Admin']); + test()->queue->update(['scan_gate_bypassed_by' => $user->id]); + + expect(deployGateReachesStart(deployGateJob()))->toBeTrue() + ->and(deployGateLogs())->toContain('Deploy anyway by Ada Admin.'); + + $event = AuditEvent::query()->where('event', 'ui.application.image_scan_gate_bypassed')->sole(); + expect($event->team_id)->toBe($this->team->id) + ->and($event->resource_uuid)->toBe($this->application->uuid) + ->and($event->actor_id)->toBe($user->id) + ->and($event->actor_name)->toBe('Ada Admin') + ->and(data_get($event->metadata, 'deployment_uuid'))->toBe('gate-deployment') + ->and(data_get($event->metadata, 'user_id'))->toBe($user->id); +}); + +it('starts the deployment of an additional server when the main deployment was deployed anyway', function () { + deployGatePolicy(); + ScanImage::shouldRun()->never(); + deployGateStoredScan([deployGateFindingRow()]); + $user = User::factory()->create(['name' => 'Ada Admin']); + ApplicationDeploymentQueue::create([ + 'deployment_uuid' => 'gate-parent', + 'application_id' => $this->application->id, + 'server_id' => $this->server->id, + 'status' => ApplicationDeploymentStatus::FINISHED->value, + 'scan_gate_bypassed_by' => $user->id, + ]); + test()->queue->update(['parent_deployment_uuid' => 'gate-parent']); + + expect(deployGateReachesStart(deployGateJob(['is_this_additional_server' => true])))->toBeTrue() + ->and(deployGateLogs())->toContain('Deploy anyway by Ada Admin.'); +}); + +it('deploys with a warning when scanning is off on the server and the rule says deploy', function () { + deployGatePolicy(onScanError: 'deploy'); + ScanImage::shouldRun()->never(); + $this->server->settings->forceFill(['is_image_scan_enabled' => false])->saveQuietly(); + + expect(deployGateReachesStart(deployGateJob(['server' => $this->server->fresh(), 'mainServer' => $this->server->fresh()])))->toBeTrue() + ->and(deployGateLogs())->toContain('Scan skipped: Turn on image scanning on main-server.') + ->and(test()->queue->fresh()->scan_gate_result)->toMatchArray(['blocked' => false, 'error' => ImageScanErrorCode::ScanningOff->value]); +}); + +it('blocks the deploy when scanning is off on the server and the rule says block', function () { + deployGatePolicy(onScanError: 'block'); + ScanImage::shouldRun()->never(); + $this->server->settings->forceFill(['is_image_scan_enabled' => false])->saveQuietly(); + + $exception = deployGateBlocked(deployGateJob(['server' => $this->server->fresh(), 'mainServer' => $this->server->fresh()])); + + expect($exception->getMessage())->toBe('Deploy blocked: Turn on image scanning on main-server.') + ->and(test()->queue->fresh()->scan_gate_result['blocked'])->toBeTrue(); +}); + +it('applies the scan error rule when the scan fails with an unexpected error', function (string $onScanError, bool $starts) { + deployGatePolicy(onScanError: $onScanError); + ScanImage::shouldRun()->once()->andThrow(new RuntimeException('database went away')); + $job = deployGateJob(); + + if ($starts) { + expect(deployGateReachesStart($job))->toBeTrue(); + } else { + expect(deployGateBlocked($job)->getMessage())->toBe('Deploy blocked: '.ImageScanErrorCode::Unknown->message()); + } + + expect(test()->queue->fresh()->scan_gate_result)->toMatchArray(['blocked' => ! $starts, 'error' => ImageScanErrorCode::Unknown->value]); +})->with([ + 'deploy on error' => ['deploy', true], + 'block on error' => ['block', false], +]); + +/** + * Lets ScanImage::run() take the server lock like the real action, and returns a clean scan or a busy lock. + */ +function deployGateScanWithServerLock(): void +{ + ScanImage::shouldRun()->andReturnUsing(function (Server $server, $resource, $ref, $deployment, int $lockWaitSeconds) { + $lock = ScanImage::serverLock($server); + if (! ScanImage::acquireServerLock($lock, $server, $lockWaitSeconds)) { + return (new ImageScan)->forceFill(['status' => ImageScanStatus::Failed, 'error_code' => ImageScanErrorCode::LockBusy, 'error_context' => ['server' => $server->name]]); + } + $lock->release(); + + return deployGateReturnedScan([]); + }); +} + +it('waits for a background scan that holds the server lock longer than one lock wait', function () { + deployGatePolicy(onScanError: 'block'); + deployGateScanWithServerLock(); + $background = ScanImage::serverLock($this->server); + expect($background->get())->toBeTrue(); + $releaseAt = now()->addSeconds(600); + Sleep::fake(syncWithCarbon: true); + Sleep::whenFakingSleep(function () use ($background, $releaseAt) { + if (now()->gte($releaseAt)) { + $background->release(); + } + }); + + expect(deployGateReachesStart(deployGateJob()))->toBeTrue() + ->and(test()->queue->fresh()->scan_gate_result)->toMatchArray(['blocked' => false, 'error' => null]) + ->and(deployGateLogs())->toContain('Another image scan is running on main-server.'); +}); + +it('stops waiting for the server lock before the deployment times out', function () { + deployGatePolicy(onScanError: 'block'); + deployGateScanWithServerLock(); + expect(ScanImage::serverLock($this->server)->get())->toBeTrue(); + $startedAt = now(); + Sleep::fake(syncWithCarbon: true); + $job = deployGateJob(); + + expect(deployGateBlocked($job)->getMessage())->toBe('Deploy blocked: '.ImageScanErrorCode::LockBusy->message(['server' => 'main-server'])) + ->and((int) $startedAt->diffInSeconds(now()))->toBeLessThanOrEqual($job->timeout - ScanImage::GATE_SCAN_RESERVE_SECONDS + 1); +}); + +it('stops waiting for the server lock when the deployment is cancelled', function () { + deployGatePolicy(onScanError: 'block'); + deployGateScanWithServerLock(); + expect(ScanImage::serverLock($this->server)->get())->toBeTrue(); + $cancelAt = now()->addSeconds(300); + Sleep::fake(syncWithCarbon: true); + Sleep::whenFakingSleep(function () use ($cancelAt) { + if (now()->gte($cancelAt)) { + test()->queue->update(['status' => ApplicationDeploymentStatus::CANCELLED_BY_USER->value]); + } + }); + + expect(deployGateBlocked(deployGateJob())->getCode())->toBe(69420) + ->and(test()->queue->fresh()->scan_gate_result)->toBeNull(); +}); + +it('pulls a Docker image, pins its digest, and starts it without a compose pull', function () { + deployGatePolicy(); + ScanImage::shouldRun()->once() + ->withArgs(fn ($server, $resource, $ref) => $ref === 'nginx:1.27') + ->andReturn(deployGateReturnedScan([])); + $this->application->update(['build_pack' => 'dockerimage', 'docker_registry_image_name' => 'nginx', 'docker_registry_image_tag' => '1.27']); + $job = deployGateJob(['production_image_name' => 'nginx:1.27']); + $job->outputs = ['docker image inspect' => GATE_IMAGE_ID.' nginx@'.GATE_REPO_DIGEST.' ']; + + expect(deployGateReachesStart($job))->toBeTrue(); + + $compose = (new ReflectionProperty(ApplicationDeploymentJob::class, 'docker_compose'))->getValue($job); + expect(Yaml::parse($compose)['services']['gate-app']['image'])->toBe('nginx@'.GATE_REPO_DIGEST) + ->and($compose)->toContain('nginx@sha256:') + ->and(collect($job->commands)->first(fn (string $command) => str_contains($command, 'docker pull')))->toContain("docker pull 'nginx:1.27'") + ->and(collect($job->commands)->contains(fn (string $command) => preg_match('/docker compose .* pull/', $command) === 1))->toBeFalse(); +}); + +it('keeps the compose pull of a Docker image when the gate is off', function () { + ScanImage::shouldRun()->never(); + $this->application->update(['build_pack' => 'dockerimage', 'docker_registry_image_name' => 'nginx', 'docker_registry_image_tag' => '1.27']); + $job = deployGateJob(['production_image_name' => 'nginx:1.27']); + + expect(deployGateReachesStart($job))->toBeTrue() + ->and(collect($job->commands)->contains(fn (string $command) => preg_match('/docker compose .* pull/', $command) === 1))->toBeTrue() + ->and(Yaml::parse((new ReflectionProperty(ApplicationDeploymentJob::class, 'docker_compose'))->getValue($job))['services']['gate-app']['image'])->toBe('nginx:1.27'); +}); + +it('gates a built image without a repo digest by its local image ID and does not pin it', function () { + deployGatePolicy(); + ScanImage::shouldRun()->never(); + deployGateStoredScan([deployGateFindingRow()]); + $buildServer = deployGateServer($this->team, 'build-server'); + $buildServer->settings->forceFill(['is_build_server' => true])->saveQuietly(); + $job = deployGateJob(['use_build_server' => true, 'build_server' => $buildServer, 'server' => $buildServer]); + $job->outputs = ['docker image inspect' => GATE_IMAGE_ID.' ']; + + expect(deployGateBlocked($job)->getMessage())->toBe(DEPLOY_GATE_HEADLINE) + ->and(Yaml::parse((new ReflectionProperty(ApplicationDeploymentJob::class, 'docker_compose'))->getValue($job))['services']['gate-app']['image']) + ->toBe($this->application->uuid.':'.GATE_COMMIT); +}); + +it('pins the pushed digest of a build server image and starts it without --pull always', function () { + deployGatePolicy(); + ScanImage::shouldRun()->once() + ->withArgs(fn ($server) => $server->name === 'build-server') + ->andReturn(deployGateReturnedScan([])); + $this->application->update(['docker_registry_image_name' => 'ghcr.io/team/app']); + $buildServer = deployGateServer($this->team, 'build-server'); + $imageRef = 'ghcr.io/team/app:'.GATE_COMMIT; + $job = deployGateJob(['use_build_server' => true, 'build_server' => $buildServer, 'server' => $buildServer, 'production_image_name' => $imageRef]); + $job->outputs = ['docker image inspect' => GATE_IMAGE_ID.' ghcr.io/team/app@'.GATE_REPO_DIGEST.' ']; + + expect(deployGateReachesStart($job))->toBeTrue(); + + $compose = (new ReflectionProperty(ApplicationDeploymentJob::class, 'generated_docker_compose'))->getValue($job); + expect(Yaml::parse($compose)['services']['gate-app']['image'])->toBe('ghcr.io/team/app@'.GATE_REPO_DIGEST) + ->and(deployGateStartCommand($job))->toContain(' up --build -d')->not->toContain('--pull always'); +}); + +it('resolves the rules with the build server and reports when scanning is off there', function () { + deployGatePolicy(onScanError: 'block'); + ScanImage::shouldRun()->never(); + $buildServer = deployGateServer($this->team, 'build-server', scanning: false); + $job = deployGateJob(['use_build_server' => true, 'build_server' => $buildServer, 'server' => $buildServer]); + + expect(deployGateBlocked($job)->getMessage())->toBe('Deploy blocked: Image scanning is off on the build server.') + ->and(test()->queue->fresh()->scan_gate_result['error'])->toBe(ImageScanErrorCode::BuildServerOff->value); +}); + +it('scans a restart on the deployment server with its rules when the application has a build server', function () { + deployGatePolicy(onScanError: 'block'); + $buildServer = deployGateServer($this->team, 'build-server', scanning: false); + ScanImage::shouldRun()->once() + ->withArgs(fn ($server) => $server->is(test()->server)) + ->andReturn(deployGateReturnedScan([])); + $job = deployGateJob(['use_build_server' => true, 'build_server' => $buildServer, 'restart_only' => true]); + + expect(deployGateReachesStart($job))->toBeTrue() + ->and(test()->queue->fresh()->scan_gate_result)->toMatchArray(['blocked' => false, 'error' => null, 'scan_ran' => true]); +}); + +it('blocks a Compose deploy with a custom start command when the rule says block', function () { + deployGatePolicy(onScanError: 'block'); + ScanImage::shouldRun()->never(); + $this->application->update(['build_pack' => 'dockercompose']); + $job = deployGateJob(['docker_compose_custom_start_command' => 'docker compose up -d']); + + try { + deployGateImageScan($job)->checkComposeGate(); + $this->fail('Expected the gate to block the deploy.'); + } catch (DeploymentException $exception) { + expect($exception->getMessage())->toBe('Deploy blocked: Coolify cannot check the image because this application uses a custom build or start command.'); + } +}); + +it('reports the custom command for a raw Compose deploy whose services have no image', function () { + deployGatePolicy(onScanError: 'block'); + ScanImage::shouldRun()->never(); + $this->application->update(['build_pack' => 'dockercompose']); + $job = deployGateJob([ + 'docker_compose_custom_build_command' => 'docker compose build', + 'generated_docker_compose' => Yaml::dump(['services' => ['web' => ['build' => '.']]], 10), + ]); + + try { + deployGateImageScan($job)->checkComposeGate(); + $this->fail('Expected the gate to block the deploy.'); + } catch (DeploymentException $exception) { + expect($exception->getMessage())->toBe('Deploy blocked: Coolify cannot check the image because this application uses a custom build or start command.') + ->and(collect($job->commands)->contains(fn (string $command) => str_contains($command, 'docker image inspect')))->toBeFalse(); + } +}); + +it('keeps the worst gate result of a Compose deploy', function () { + deployGatePolicy(onScanError: 'deploy'); + $this->application->update(['build_pack' => 'dockercompose']); + ScanImage::shouldRun()->twice()->andReturn( + (new ImageScan)->forceFill(['status' => ImageScanStatus::Failed, 'error_code' => ImageScanErrorCode::Timeout, 'error_context' => []]), + deployGateReturnedScan([]), + ); + $job = deployGateJob(['generated_docker_compose' => Yaml::dump(['services' => [ + 'web' => ['image' => 'nginx:1.27'], + 'cache' => ['image' => 'redis:7'], + ]], 10)]); + $job->outputs = ['docker image inspect' => '']; + + deployGateImageScan($job)->checkComposeGate(); + + expect(test()->queue->fresh()->scan_gate_result)->toMatchArray(['blocked' => false, 'error' => ImageScanErrorCode::Timeout->value]); +}); + +it('scans each Compose image and stops Compose from pulling the scanned images', function () { + deployGatePolicy(); + ScanImage::shouldRun()->twice()->andReturn(deployGateReturnedScan([])); + $this->application->update(['build_pack' => 'dockercompose']); + $compose = Yaml::dump(['services' => [ + 'web' => ['image' => 'nginx:1.27'], + 'worker' => ['image' => $this->application->uuid.'_worker:'.GATE_COMMIT, 'build' => ['context' => '.']], + ]], 10); + $job = deployGateJob(['generated_docker_compose' => $compose]); + + deployGateImageScan($job)->checkComposeGate(); + + $services = Yaml::parse((new ReflectionProperty(ApplicationDeploymentJob::class, 'generated_docker_compose'))->getValue($job))['services']; + expect($services['web']['pull_policy'])->toBe('never') + ->and($services['worker']['pull_policy'])->toBe('never') + ->and(collect($job->commands)->contains(fn (string $command) => str_contains($command, 'tee /artifacts/gate-deployment/docker-compose.yaml')))->toBeTrue(); +}); + +it('scans the started images one at a time on the deployment server in the queued job', function () { + $scanned = []; + ScanImage::shouldRun()->twice() + ->withArgs(function ($server, $resource, $ref, $deployment, $wait) use (&$scanned) { + $scanned[] = $ref; + + return $server->is(test()->server) && $resource->is(test()->application) && $deployment->id === test()->queue->id && $wait === 0; + }) + ->andReturn(deployGateReturnedScan([])); + + $job = new ScanResourceImagesJob($this->application, $this->queue->id, ['nginx:1.27', 'redis:7']); + $job->handle(); + + expect($scanned)->toBe(['nginx:1.27', 'redis:7']) + ->and($job->timeout)->toBe(2 * ScanImage::LOCK_TTL); +}); + +it('scans the images of the latest completed scans when no image is given', function () { + deployGateStoredScan([], ['image_ref' => 'old:1', 'image_id' => 'sha256:'.str_repeat('c', 64), 'scanned_at' => now()->subDay()]); + deployGateStoredScan([], ['image_ref' => 'new:2', 'scanned_at' => now()]); + deployGateStoredScan([], ['image_ref' => 'failed:3', 'image_id' => 'sha256:'.str_repeat('d', 64), 'status' => ImageScanStatus::Failed]); + $scanned = []; + ScanImage::shouldRun()->twice() + ->withArgs(function ($server, $resource, $ref) use (&$scanned) { + $scanned[] = $ref; + + return true; + }) + ->andReturn(deployGateReturnedScan([])); + + (new ScanResourceImagesJob($this->application))->handle(); + + expect($scanned)->toBe(['new:2', 'old:1']); +}); + +/** + * A job after the build of a Dockerfile app: the Dockerfile was read from the repository. + */ +function deployGateDockerfileJob(string $dockerfile = "FROM node:20.11-alpine AS build\nRUN npm ci\nFROM build\n", array $overrides = []): DeploymentGateTestJob +{ + return deployGateJob(['saved_outputs' => new Collection(['dockerfile_from_repo' => str($dockerfile)]), ...$overrides]); +} + +function deployGateBaseImageRef(DeploymentGateTestJob $job): ?string +{ + return deployGateImageScan($job)->baseImageRef(); +} + +it('passes the base image of a Dockerfile build to the gate scan without a remote command', function () { + deployGatePolicy(); + ScanImage::shouldRun()->once() + ->withArgs(fn ($server, $resource, $ref, $deployment, $wait, $baseImageRef) => $baseImageRef === 'node:20.11-alpine') + ->andReturn(deployGateReturnedScan([])); + $job = deployGateDockerfileJob(); + + deployGateImageScan($job)->rememberDockerfileBaseImage(); + + expect($job->commands)->toBe([]) + ->and(deployGateReachesStart($job))->toBeTrue(); +}); + +it('passes the base image of a Dockerfile build to the scan after the deploy', function () { + Queue::fake(); + $job = deployGateDockerfileJob(); + + deployGateImageScan($job)->rememberDockerfileBaseImage(); + deployGateCall($job, 'post_deployment'); + + Queue::assertPushed(ScanResourceImagesJob::class, fn (ScanResourceImagesJob $scanJob) => $scanJob->baseImageRef === 'node:20.11-alpine'); +}); + +it('scans the started image with the base image in the queued job', function () { + ScanImage::shouldRun()->once() + ->withArgs(fn ($server, $resource, $ref, $deployment, $wait, $baseImageRef) => $ref === 'app:1' && $baseImageRef === 'node:20.11-alpine') + ->andReturn(deployGateReturnedScan([])); + + (new ScanResourceImagesJob($this->application, $this->queue->id, ['app:1'], false, 'node:20.11-alpine'))->handle(); +}); + +it('reads the base image of an inline Dockerfile and of the build target', function (array $application, string $expected) { + $this->application->update($application); + $job = deployGateDockerfileJob("FROM alpine:3.20\n"); + + deployGateImageScan($job)->rememberDockerfileBaseImage(); + + expect(deployGateBaseImageRef($job))->toBe($expected); +})->with([ + 'inline Dockerfile' => [['dockerfile' => "FROM nginx:1.27-alpine\nCOPY . /usr/share/nginx/html\n"], 'nginx:1.27-alpine'], + 'build target' => [['dockerfile' => "FROM node:20 AS dev\nFROM nginx:1.27 AS prod\n", 'dockerfile_target_build' => 'dev'], 'node:20'], +]); + +it('reads no base image when it cannot be known or scanning is off', function (string $dockerfile, string $buildPack, bool $scanning) { + $this->application->update(['build_pack' => $buildPack]); + $this->server->settings->forceFill(['is_image_scan_enabled' => $scanning])->saveQuietly(); + $job = deployGateDockerfileJob($dockerfile, ['server' => $this->server->fresh(), 'mainServer' => $this->server->fresh()]); + + deployGateImageScan($job)->rememberDockerfileBaseImage(); + + expect(deployGateBaseImageRef($job))->toBeNull(); +})->with([ + 'ARG in FROM' => ["ARG BASE=node:20\nFROM \${BASE}\n", 'dockerfile', true], + 'scratch' => ["FROM golang:1.23 AS build\nFROM scratch\n", 'dockerfile', true], + 'nixpacks' => ["FROM node:20\n", 'nixpacks', true], + 'scanning off on the server' => ["FROM node:20\n", 'dockerfile', false], +]); + +it('reads no scan rules in a deploy when the app scan is off', function () { + deployGatePolicy(onScanError: 'block'); + Queue::fake(); + $this->application->settings->forceFill(['is_image_scan_enabled' => false])->saveQuietly(); + $job = deployGateDockerfileJob("FROM node:20\n", ['server' => $this->server->fresh(), 'mainServer' => $this->server->fresh()]); + + DB::enableQueryLog(); + deployGateImageScan($job)->rememberDockerfileBaseImage(); + $applies = deployGateImageScan($job)->gateApplies(); + deployGateImageScan($job)->dispatchScanAfterStart(); + $queries = collect(DB::getQueryLog())->pluck('query'); + DB::disableQueryLog(); + + expect($applies)->toBeFalse() + ->and(deployGateBaseImageRef($job))->toBeNull() + ->and($queries->filter(fn (string $query) => str_contains($query, 'image_scan_policies')))->toBeEmpty(); + Queue::assertNotPushed(ScanResourceImagesJob::class); +}); + +it('does not fail the deploy when the Dockerfile cannot be read', function () { + $job = deployGateJob(['saved_outputs' => new Collection]); + + deployGateImageScan($job)->rememberDockerfileBaseImage(); + + expect(deployGateBaseImageRef($job))->toBeNull(); +}); + +it('gates a build server image with the rules of the main destination server', function () { + ImageScanPolicy::query()->create(['scope_type' => $this->server->getMorphClass(), 'scope_id' => $this->server->id, 'gate_mode' => 'strict']); + $buildServer = deployGateServer($this->team, 'build-server'); + ImageScanPolicy::query()->create(['scope_type' => $buildServer->getMorphClass(), 'scope_id' => $buildServer->id, 'gate_mode' => 'off']); + ScanImage::shouldRun()->once() + ->withArgs(fn ($server) => $server->is($buildServer)) + ->andReturn(deployGateReturnedScan([deployGateFindingRow('high', kev: false)])); + $job = deployGateJob(['use_build_server' => true, 'build_server' => $buildServer, 'server' => $buildServer]); + + expect(deployGateBlocked($job)->getMessage())->toBe('Deploy blocked: this image has a security problem that needs a security update.'); +}); + +/** + * A stored scan of nginx:1.27, an image that the team does not build, with a critical package fix. + */ +function deployGateOfficialImageScan(?array $candidates = null): ImageScan +{ + return deployGateStoredScan([deployGateFindingRow()], [ + 'image_ref' => 'nginx:1.27', + 'builds_image' => false, + 'candidates' => $candidates, + 'deployment_queue_id' => test()->queue->id, + ]); +} + +function deployGateOfficialImageJob(): DeploymentGateTestJob +{ + test()->application->update(['build_pack' => 'dockerimage', 'docker_registry_image_name' => 'nginx', 'docker_registry_image_tag' => '1.27']); + $job = deployGateJob(['production_image_name' => 'nginx:1.27']); + $job->outputs = ['docker image inspect' => GATE_IMAGE_ID.' nginx@'.GATE_REPO_DIGEST.' ']; + + return $job; +} + +it('does not wait for newer versions of an official image and queues their check', function () { + deployGatePolicy(); + Queue::fake(); + $job = deployGateOfficialImageJob(); + ScanImage::shouldRun()->once() + ->withArgs(fn ($server, $resource, $ref, $deployment, $wait, $baseImageRef, $queueCandidateCheck) => $queueCandidateCheck === false) + ->andReturnUsing(fn () => deployGateOfficialImageScan()); + RefreshCandidates::shouldRun()->never(); + + expect(deployGateBlocked($job)->getMessage())->toBe(DEPLOY_GATE_HEADLINE) + ->and(test()->queue->fresh()->scan_gate_result['repo_digest'])->toBe('nginx@'.GATE_REPO_DIGEST); + Queue::assertPushed(RefreshCandidatesJob::class, fn (RefreshCandidatesJob $refresh) => $refresh->scan->image_ref === 'nginx:1.27'); +}); + +it('queues the check of newer versions for an image the team builds', function () { + deployGatePolicy(); + Queue::fake(); + $job = deployGateJob(); + ScanImage::shouldRun()->once()->andReturnUsing(fn () => deployGateStoredScan([deployGateFindingRow()], ['base_image_ref' => 'node:20.11-alpine', 'deployment_queue_id' => test()->queue->id])); + RefreshCandidates::shouldRun()->never(); + + deployGateBlocked($job); + + Queue::assertPushed(RefreshCandidatesJob::class, fn (RefreshCandidatesJob $refresh) => $refresh->scan->base_image_ref === 'node:20.11-alpine'); +}); + +/** + * A Compose redeploy (D3) after the finished D2 that ran web:1, cache:1, and worker:1. D3 changes web to web:2 and + * reuses the scans of cache:1 and worker:1. + * + * @param list> $webFindings The findings of web:2. + * @return array{job: DeploymentGateTestJob, previous: ApplicationDeploymentQueue, reused: list} + */ +function deployGateComposeRedeploy(array $webFindings = []): array +{ + deployGatePolicy(); + test()->application->update(['build_pack' => 'dockercompose']); + $previous = deployGatePreviousDeployment('gate-previous', ApplicationDeploymentStatus::FINISHED); + // The deployment that runs now is newer than the one before it. + test()->queue = deployGatePreviousDeployment('gate-deployment-2', ApplicationDeploymentStatus::IN_PROGRESS); + $idOf = fn (string $char) => 'sha256:'.str_repeat($char, 64); + deployGateStoredScan([], ['image_ref' => 'web:1', 'image_id' => $idOf('1'), 'deployment_queue_id' => $previous->id, 'scanned_at' => now()->subHour()]); + $reused = [ + deployGateStoredScan([], ['image_ref' => 'cache:1', 'image_id' => $idOf('2'), 'deployment_queue_id' => $previous->id, 'scanned_at' => now()->subHour()])->id, + deployGateStoredScan([], ['image_ref' => 'worker:1', 'image_id' => $idOf('3'), 'deployment_queue_id' => $previous->id, 'scanned_at' => now()->subHour()])->id, + ]; + ScanImage::shouldRun()->once() + ->withArgs(fn ($server, $resource, $ref) => $ref === 'web:2') + ->andReturnUsing(fn () => deployGateStoredScan($webFindings, ['image_ref' => 'web:2', 'image_id' => $idOf('4'), 'deployment_queue_id' => test()->queue->id, 'scanned_at' => now()])); + $job = deployGateJob(['generated_docker_compose' => Yaml::dump(['services' => [ + 'cache' => ['image' => 'cache:1'], + 'worker' => ['image' => 'worker:1'], + 'web' => ['image' => 'web:2'], + ]], 10)]); + $job->outputs = ["'web:2'" => $idOf('4').' ', "'cache:1'" => $idOf('2').' ', "'worker:1'" => $idOf('3').' ']; + + return ['job' => $job, 'previous' => $previous, 'reused' => $reused]; +} + +function deployGateCurrentImages(): array +{ + return ResourceScans::currentFor(test()->application->fresh())->pluck('image_ref')->sort()->values()->all(); +} + +it('keeps the images of the finished deployment current while a Compose redeploy runs', function () { + ['job' => $job, 'previous' => $previous, 'reused' => $reused] = deployGateComposeRedeploy(); + + deployGateImageScan($job)->checkComposeGate(); + + expect(deployGateCurrentImages())->toBe(['cache:1', 'web:1', 'worker:1']) + ->and(ImageScan::query()->whereKey($reused)->pluck('deployment_queue_id')->unique()->all())->toBe([$previous->id]); +}); + +it('keeps the images of the finished deployment current when the redeploy is blocked', function () { + ['job' => $job, 'previous' => $previous, 'reused' => $reused] = deployGateComposeRedeploy([deployGateFindingRow()]); + + expect(fn () => deployGateImageScan($job)->checkComposeGate())->toThrow(DeploymentException::class); + $job->failed(new DeploymentException('blocked')); + + expect(test()->queue->fresh()->status)->toBe(ApplicationDeploymentStatus::FAILED->value) + ->and(deployGateCurrentImages())->toBe(['cache:1', 'web:1', 'worker:1']) + ->and(ImageScan::query()->whereKey($reused)->pluck('deployment_queue_id')->unique()->all())->toBe([$previous->id]); +}); + +it('links the reused scans to the redeploy when it finished so they stay current', function () { + Queue::fake(); + ['job' => $job, 'reused' => $reused] = deployGateComposeRedeploy(); + + deployGateImageScan($job)->checkComposeGate(); + deployGateCall($job, 'post_deployment'); + + expect(test()->queue->fresh()->status)->toBe(ApplicationDeploymentStatus::FINISHED->value) + ->and(ImageScan::query()->whereKey($reused)->pluck('deployment_queue_id')->unique()->all())->toBe([test()->queue->id]) + ->and(deployGateCurrentImages())->toBe(['cache:1', 'web:2', 'worker:1']); +}); + +it('does not link the reused scans when the deployment was marked failed before it finished', function () { + Queue::fake(); + ['job' => $job, 'previous' => $previous, 'reused' => $reused] = deployGateComposeRedeploy(); + + deployGateImageScan($job)->checkComposeGate(); + // For example, another process marked it failed: completeDeployment() then changes nothing. + test()->queue->update(['status' => ApplicationDeploymentStatus::FAILED->value]); + deployGateCall($job, 'post_deployment'); + + expect(test()->queue->fresh()->status)->toBe(ApplicationDeploymentStatus::FAILED->value) + ->and(ImageScan::query()->whereKey($reused)->pluck('deployment_queue_id')->unique()->all())->toBe([$previous->id]); +}); + +it('keeps the link of a reused scan for a preview deployment', function () { + deployGatePolicy(); + $previous = deployGatePreviousDeployment('gate-previous', ApplicationDeploymentStatus::FINISHED); + $scan = deployGateStoredScan([], ['deployment_queue_id' => $previous->id]); + ScanImage::shouldRun()->never(); + test()->queue->update(['pull_request_id' => 3]); + + expect(deployGateReachesStart(deployGateJob(['pull_request_id' => 3])))->toBeTrue() + ->and($scan->fresh()->deployment_queue_id)->toBe($previous->id); +}); + +it('scans a Compose image after the start when its gate scan could not run', function () { + deployGatePolicy(onScanError: 'deploy'); + Queue::fake(); + $this->application->update(['build_pack' => 'dockercompose']); + ScanImage::shouldRun()->twice()->andReturn( + deployGateReturnedScan([]), + (new ImageScan)->forceFill(['status' => ImageScanStatus::Failed, 'error_code' => ImageScanErrorCode::Timeout, 'error_context' => []]), + ); + $job = deployGateJob(['generated_docker_compose' => Yaml::dump(['services' => [ + 'web' => ['image' => 'nginx:1.27'], + 'cache' => ['image' => 'redis:7'], + ]], 10)]); + + deployGateImageScan($job)->checkComposeGate(); + deployGateCall($job, 'post_deployment'); + + Queue::assertPushed(ScanResourceImagesJob::class, fn (ScanResourceImagesJob $scanJob) => $scanJob->imageRefs === ['redis:7']); +}); + +it('starts the blocked image by its digest when a user deployed a Docker image anyway', function () { + deployGatePolicy(); + ScanImage::shouldRun()->never(); + $this->application->update(['build_pack' => 'dockerimage', 'docker_registry_image_name' => 'nginx', 'docker_registry_image_tag' => 'latest']); + $user = User::factory()->create(['name' => 'Ada Admin']); + test()->queue->update(['scan_gate_bypassed_by' => $user->id, 'scan_gate_result' => ['repo_digest' => 'nginx@'.GATE_REPO_DIGEST]]); + $job = deployGateJob(['production_image_name' => 'nginx:latest']); + + expect(deployGateReachesStart($job))->toBeTrue(); + + $compose = (new ReflectionProperty(ApplicationDeploymentJob::class, 'docker_compose'))->getValue($job); + expect(Yaml::parse($compose)['services']['gate-app']['image'])->toBe('nginx@'.GATE_REPO_DIGEST) + ->and(collect($job->commands)->contains(fn (string $command) => str_contains($command, 'docker pull') || preg_match('/docker compose .* pull/', $command) === 1))->toBeFalse() + ->and(deployGateLogs())->toContain('Deploy anyway by Ada Admin.'); +}); + +/** + * A scan of the image that the finished deployment `gate-running` started. + * + * @param list> $findings + */ +function deployGateRunningScan(array $findings, string $imageId = 'sha256:cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc', ApplicationDeploymentStatus $status = ApplicationDeploymentStatus::FINISHED): ImageScan +{ + $running = deployGatePreviousDeployment('gate-running', $status, commit: 'fedcba9876543210fedcba9876543210fedcba98'); + + return deployGateStoredScan($findings, ['image_id' => $imageId, 'deployment_queue_id' => $running->id, 'scanned_at' => now()->subHour()]); +} + +it('starts a new image whose exploited problem the running image has too and warns about it', function () { + deployGatePolicy(); + deployGateRunningScan([deployGateFindingRow()]); + ScanImage::shouldRun()->once()->andReturn(deployGateReturnedScan([deployGateFindingRow()])); + + expect(deployGateReachesStart(deployGateJob()))->toBeTrue() + ->and(test()->queue->fresh()->scan_gate_result)->toMatchArray(['blocked' => false, 'scan_ran' => true, 'mode' => 'on', 'carried_count' => 1]) + ->and(deployGateLogs())->toContain('Image scan: passed.') + ->toContain('Already in the running image: a security problem that attackers are using.') + ->toContain('CVE-2026-1234 Critical openssl 3.0.13 already running'); +}); + +it('blocks a new image that adds an exploited problem to the running image', function () { + deployGatePolicy(); + deployGateRunningScan([deployGateFindingRow()]); + ScanImage::shouldRun()->once()->andReturn(deployGateReturnedScan([deployGateFindingRow(), deployGateFindingRow(id: 'CVE-2026-9999')])); + + expect(deployGateBlocked(deployGateJob())->getMessage())->toBe(DEPLOY_GATE_HEADLINE) + ->and(test()->queue->fresh()->scan_gate_result['top'][0]['id'])->toBe('CVE-2026-9999') + ->and(test()->queue->fresh()->scan_gate_result['carried'][0]['id'])->toBe('CVE-2026-1234'); +}); + +it('starts a redeploy of the running image when the gate is on', function () { + deployGatePolicy(); + ScanImage::shouldRun()->never(); + $scan = deployGateRunningScan([deployGateFindingRow()], imageId: GATE_IMAGE_ID); + $runningDeploymentId = $scan->deployment_queue_id; + + // The reused scan joins this deployment only when it finishes. + expect(deployGateReachesStart(deployGateJob()))->toBeTrue() + ->and($scan->fresh()->deployment_queue_id)->toBe($runningDeploymentId); +}); + +it('does not count the image of a blocked deployment as running', function () { + deployGatePolicy(); + deployGateRunningScan([deployGateFindingRow()], status: ApplicationDeploymentStatus::FAILED); + ScanImage::shouldRun()->once()->andReturn(deployGateReturnedScan([deployGateFindingRow()])); + + expect(deployGateBlocked(deployGateJob())->getMessage())->toBe(DEPLOY_GATE_HEADLINE); +}); + +it('does not block and starts a baseline when the app ran before but has no earlier scan', function () { + deployGatePolicy(); + deployGatePreviousDeployment('gate-before-scanning', ApplicationDeploymentStatus::FINISHED, commit: str_repeat('1', 40)); + ScanImage::shouldRun()->once()->andReturn(deployGateReturnedScan([deployGateFindingRow()])); + + expect(deployGateReachesStart(deployGateJob()))->toBeTrue() + ->and(test()->queue->fresh()->scan_gate_result)->toMatchArray(['blocked' => false, 'scan_ran' => true, 'baseline_missing' => true, 'carried_count' => 1]) + ->and(deployGateLogs())->toContain('Image scan: passed.') + ->toContain('Coolify has no earlier scan of this app to compare with. This deploy becomes the baseline.') + ->toContain('This image has a security problem that attackers are using.') + ->not->toContain('already running'); +}); + +it('compares with the newest earlier scan when the app ran before without a scan of its deployment', function (array $newFindings, bool $blocked) { + deployGatePolicy(); + deployGatePreviousDeployment('gate-before-scanning', ApplicationDeploymentStatus::FINISHED, commit: str_repeat('1', 40)); + // A scan without a deployment (taken before any deployment finished) of an image with the exploited finding. + deployGateStoredScan([deployGateFindingRow()], ['image_id' => 'sha256:'.str_repeat('e', 64), 'deployment_queue_id' => null, 'scanned_at' => now()->subDay()]); + // The image of a deployment the gate blocked is never the baseline. + $blockedDeployment = deployGatePreviousDeployment('gate-blocked', ApplicationDeploymentStatus::FAILED, commit: str_repeat('2', 40), attributes: ['scan_gate_result' => ['blocked' => true]]); + deployGateStoredScan([deployGateFindingRow(), deployGateFindingRow(id: 'CVE-2026-9999')], ['image_id' => 'sha256:'.str_repeat('f', 64), 'deployment_queue_id' => $blockedDeployment->id, 'scanned_at' => now()->subHour()]); + ScanImage::shouldRun()->once()->andReturn(deployGateReturnedScan($newFindings)); + + if ($blocked) { + expect(deployGateBlocked(deployGateJob())->getMessage())->toBe(DEPLOY_GATE_HEADLINE) + ->and(test()->queue->fresh()->scan_gate_result['top'][0]['id'])->toBe('CVE-2026-9999'); + } else { + expect(deployGateReachesStart(deployGateJob()))->toBeTrue() + ->and(test()->queue->fresh()->scan_gate_result)->toMatchArray(['blocked' => false, 'baseline_missing' => false, 'carried_count' => 1]); + } +})->with([ + 'same exploited finding' => [[deployGateFindingRow()], false], + 'a new exploited finding' => [[deployGateFindingRow(), deployGateFindingRow(id: 'CVE-2026-9999')], true], +]); + +it('counts every finding as new on the first deploy', function () { + deployGatePolicy(); + ScanImage::shouldRun()->once()->andReturn(deployGateReturnedScan([deployGateFindingRow()])); + + expect(deployGateBlocked(deployGateJob())->getMessage())->toBe(DEPLOY_GATE_HEADLINE) + ->and(test()->queue->fresh()->scan_gate_result['baseline_missing'])->toBeFalse(); +}); + +/** + * A blocked first deploy of GATE_IMAGE_ID, then a finished deploy of another image whose scan failed. + */ +function deployGateBlockedThenUnscanned(bool $keepBlockedScan = true): void +{ + $blocked = deployGatePreviousDeployment('gate-blocked-x', ApplicationDeploymentStatus::FAILED, attributes: [ + 'scan_gate_result' => ['blocked' => true, 'image_id' => GATE_IMAGE_ID], + ]); + if ($keepBlockedScan) { + deployGateStoredScan([deployGateFindingRow()], ['deployment_queue_id' => $blocked->id, 'scanned_at' => now()->subDays(2)]); + } + $finished = deployGatePreviousDeployment('gate-finished-y', ApplicationDeploymentStatus::FINISHED, commit: str_repeat('2', 40), attributes: [ + 'configuration_snapshot' => test()->application->fresh()->deploymentConfigurationSnapshot(), + ]); + deployGateStoredScan([], [ + 'image_id' => 'sha256:'.str_repeat('9', 64), + 'status' => ImageScanStatus::Failed, + 'error_code' => ImageScanErrorCode::Timeout, + 'deployment_queue_id' => $finished->id, + 'scanned_at' => now()->subDay(), + ]); +} + +it('blocks a restart of an image that the gate blocked before, after a deploy whose scan failed', function () { + deployGatePolicy(); + ScanImage::shouldRun()->never(); + deployGateBlockedThenUnscanned(); + $job = deployGateJob([ + 'restart_only' => true, + 'saved_outputs' => new Collection(['local_image_found' => str('sha256:aaaa')]), + ]); + + expect(deployGateBlocked($job, 'should_skip_build')->getMessage())->toBe(DEPLOY_GATE_HEADLINE) + ->and(test()->queue->fresh()->scan_gate_result)->toMatchArray(['blocked' => true, 'baseline_missing' => false, 'image_id' => GATE_IMAGE_ID]); +}); + +it('never compares a blocked image with its own scan without a deployment', function () { + deployGatePolicy(); + ScanImage::shouldRun()->never(); + // A reused scan of a blocked deploy keeps no deployment. + deployGateBlockedThenUnscanned(keepBlockedScan: false); + deployGateStoredScan([deployGateFindingRow()], ['deployment_queue_id' => null, 'scanned_at' => now()->subHour()]); + $job = deployGateJob([ + 'restart_only' => true, + 'saved_outputs' => new Collection(['local_image_found' => str('sha256:aaaa')]), + ]); + + expect(deployGateBlocked($job, 'should_skip_build')->getMessage())->toBe(DEPLOY_GATE_HEADLINE) + ->and(test()->queue->fresh()->scan_gate_result)->toMatchArray(['blocked' => true, 'baseline_missing' => false, 'carried_count' => 0]); +}); + +it('keeps blocking an image whose blocked scan was removed', function () { + deployGatePolicy(); + deployGateBlockedThenUnscanned(keepBlockedScan: false); + ScanImage::shouldRun()->once()->andReturn(deployGateReturnedScan([deployGateFindingRow()])); + + expect(deployGateBlocked(deployGateJob())->getMessage())->toBe(DEPLOY_GATE_HEADLINE) + ->and(test()->queue->fresh()->scan_gate_result)->toMatchArray(['blocked' => true, 'baseline_missing' => false]); +}); + +it('blocks a critical problem with a fix only in strict mode', function (string $mode, bool $blocked) { + deployGatePolicy(mode: $mode); + deployGateRunningScan([deployGateFindingRow(kev: false)]); + ScanImage::shouldRun()->once()->andReturn(deployGateReturnedScan([deployGateFindingRow(kev: false)])); + + if ($blocked) { + expect(deployGateBlocked(deployGateJob())->getMessage())->toBe('Deploy blocked: this image has a security problem that needs a security update.'); + } else { + expect(deployGateReachesStart(deployGateJob()))->toBeTrue(); + } +})->with([ + 'on' => ['on', false], + 'strict' => ['strict', true], +]); + +it('does not gate when the mode is off', function () { + deployGatePolicy(onScanError: 'block', mode: 'off'); + ScanImage::shouldRun()->never(); + + expect(deployGateReachesStart(deployGateJob()))->toBeTrue() + ->and(test()->queue->fresh()->scan_gate_result)->toBeNull(); +}); + +it('compares with the image of the latest finished deployment, not with the newest scan', function () { + deployGatePolicy(); + $exploited = deployGateFindingRow(id: 'CVE-2026-5555'); + // c1 starts image A; c2 starts image B, which adds the exploited finding (deployed anyway). + $first = deployGatePreviousDeployment('gate-c1', ApplicationDeploymentStatus::FINISHED, commit: str_repeat('1', 40)); + $scanA = deployGateStoredScan([], ['image_id' => 'sha256:'.str_repeat('d', 64), 'deployment_queue_id' => $first->id, 'scanned_at' => now()->subDays(2)]); + $second = deployGatePreviousDeployment('gate-c2', ApplicationDeploymentStatus::FINISHED, commit: str_repeat('2', 40)); + deployGateStoredScan([$exploited], ['image_id' => 'sha256:'.str_repeat('b', 64), 'deployment_queue_id' => $second->id, 'scanned_at' => now()->subDay()]); + // A redeploy of c1 reuses the scan of image A, which keeps its older scan time. + $redeploy = deployGatePreviousDeployment('gate-c1-again', ApplicationDeploymentStatus::FINISHED, commit: str_repeat('1', 40)); + $scanA->forceFill(['deployment_queue_id' => $redeploy->id])->save(); + ScanImage::shouldRun()->once()->andReturn(deployGateReturnedScan([$exploited])); + + expect(deployGateBlocked(deployGateJob())->getMessage())->toBe(DEPLOY_GATE_HEADLINE) + ->and(ResourceScans::currentFor($this->application->fresh())->pluck('id')->all())->toBe([$scanA->id]); +}); + +it('never counts the image of a preview deployment as running', function () { + deployGatePolicy(); + $preview = deployGatePreviousDeployment('gate-preview', ApplicationDeploymentStatus::FINISHED); + $preview->forceFill(['pull_request_id' => 3])->save(); + deployGateStoredScan([deployGateFindingRow()], ['image_id' => 'sha256:'.str_repeat('c', 64), 'deployment_queue_id' => $preview->id]); + ScanImage::shouldRun()->once()->andReturn(deployGateReturnedScan([deployGateFindingRow()])); + + expect(deployGateBlocked(deployGateJob())->getMessage())->toBe(DEPLOY_GATE_HEADLINE); +}); + +it('matches the running image of a Compose service by its name', function () { + deployGatePolicy(); + $this->application->update(['build_pack' => 'dockercompose']); + $previous = deployGatePreviousDeployment('gate-previous', ApplicationDeploymentStatus::FINISHED); + $idOf = fn (string $char) => 'sha256:'.str_repeat($char, 64); + deployGateStoredScan([deployGateFindingRow()], ['image_ref' => 'web:1', 'image_id' => $idOf('1'), 'deployment_queue_id' => $previous->id, 'scanned_at' => now()->subHour()]); + deployGateStoredScan([], ['image_ref' => 'cache:1', 'image_id' => $idOf('2'), 'deployment_queue_id' => $previous->id, 'scanned_at' => now()->subMinute()]); + $scanned = []; + ScanImage::shouldRun()->twice()->andReturnUsing(function ($server, $resource, string $ref) use (&$scanned) { + $scanned[] = $ref; + + return deployGateReturnedScan([deployGateFindingRow()]); + }); + $job = deployGateJob(['generated_docker_compose' => Yaml::dump(['services' => [ + 'web' => ['image' => 'web:2'], + 'cache' => ['image' => 'cache:2'], + ]], 10)]); + $job->outputs = ["'web:2'" => $idOf('3').' ', "'cache:2'" => $idOf('4').' ']; + + try { + deployGateImageScan($job)->checkComposeGate(); + $this->fail('Expected the gate to block the deploy.'); + } catch (DeploymentException $exception) { + // web:2 has the finding of web:1 and passes; cache:2 adds it and blocks. + expect($scanned)->toBe(['web:2', 'cache:2']) + ->and($exception->getMessage())->toBe(DEPLOY_GATE_HEADLINE) + ->and(deployGateLogs())->toContain('Already in the running image: a security problem that attackers are using.'); + } +}); + +it('takes the current scans from the latest deployment, also when an older image was scanned before', function () { + $older = deployGatePreviousDeployment('gate-older', ApplicationDeploymentStatus::FINISHED); + $newer = deployGatePreviousDeployment('gate-newer', ApplicationDeploymentStatus::FINISHED); + $stale = deployGateStoredScan([], ['image_id' => 'sha256:'.str_repeat('e', 64), 'deployment_queue_id' => $older->id, 'scanned_at' => now()]); + $current = deployGateStoredScan([], ['image_id' => 'sha256:'.str_repeat('f', 64), 'deployment_queue_id' => $newer->id, 'scanned_at' => now()->subDays(3)]); + + $application = $this->application->fresh(); + + expect(ResourceScans::currentFor($application)->pluck('id')->all())->toBe([$current->id]) + ->and(ResourceScans::currentForMany(collect([$application]))->get(ResourceScans::key($application))->pluck('id')->all())->toBe([$current->id]) + ->and($stale->exists)->toBeTrue(); +}); + +it('keeps the scan of the last finished deployment current after a blocked or failed deployment', function (ApplicationDeploymentStatus $status) { + $finished = deployGatePreviousDeployment('gate-finished-d2', ApplicationDeploymentStatus::FINISHED); + $blocked = deployGatePreviousDeployment('gate-blocked-d3', $status); + $running = deployGateStoredScan([], ['image_id' => 'sha256:'.str_repeat('e', 64), 'deployment_queue_id' => $finished->id, 'scanned_at' => now()->subDay()]); + deployGateStoredScan([deployGateFindingRow()], ['image_id' => 'sha256:'.str_repeat('f', 64), 'deployment_queue_id' => $blocked->id, 'scanned_at' => now()]); + + $application = $this->application->fresh(); + + expect(ResourceScans::currentFor($application)->pluck('id')->all())->toBe([$running->id]) + ->and(ResourceScans::currentForMany(collect([$application]))->get(ResourceScans::key($application))->pluck('id')->all())->toBe([$running->id]); +})->with([ + 'failed' => [ApplicationDeploymentStatus::FAILED], + 'in progress' => [ApplicationDeploymentStatus::IN_PROGRESS], +]); + +it('falls back to the scan of a deployment that did not finish when there is no other', function () { + $failed = deployGatePreviousDeployment('gate-failed-d1', ApplicationDeploymentStatus::FAILED); + $scan = deployGateStoredScan([], ['image_id' => 'sha256:'.str_repeat('e', 64), 'deployment_queue_id' => $failed->id]); + + $application = $this->application->fresh(); + + expect(ResourceScans::currentFor($application)->pluck('id')->all())->toBe([$scan->id]) + ->and(ResourceScans::currentForMany(collect([$application]))->get(ResourceScans::key($application))->pluck('id')->all())->toBe([$scan->id]); +}); + +it('prefers a scan without a deployment over the scan of a failed deployment', function () { + $failed = deployGatePreviousDeployment('gate-failed-d1', ApplicationDeploymentStatus::FAILED); + deployGateStoredScan([], ['image_id' => 'sha256:'.str_repeat('e', 64), 'deployment_queue_id' => $failed->id, 'scanned_at' => now()]); + $unlinked = deployGateStoredScan([], ['image_id' => 'sha256:'.str_repeat('f', 64), 'deployment_queue_id' => null, 'scanned_at' => now()->subDay()]); + + expect(ResourceScans::currentFor($this->application->fresh())->pluck('id')->all())->toBe([$unlinked->id]); +}); + +it('links the reused scans right when the deployment is finished, before the post-deployment command', function () { + Queue::fake(); + ['job' => $job, 'reused' => $reused] = deployGateComposeRedeploy(); + (new ReflectionProperty(ApplicationDeploymentJob::class, 'application'))->getValue($job)->post_deployment_command = 'php artisan migrate'; + $linksAtCommand = null; + DB::listen(function (QueryExecuted $query) use (&$linksAtCommand, $reused) { + if ($linksAtCommand === null && str_contains((string) json_encode($query->bindings), 'Executing post-deployment command')) { + $linksAtCommand = ImageScan::query()->whereKey($reused)->pluck('deployment_queue_id')->unique()->values()->all(); + } + }); + + deployGateImageScan($job)->checkComposeGate(); + deployGateCall($job, 'post_deployment'); + + expect($linksAtCommand)->toBe([test()->queue->id]); +}); + +it('links the reused scans when a restart finishes', function () { + Queue::fake(); + deployGatePolicy(); + $finished = deployGatePreviousDeployment('gate-finished', ApplicationDeploymentStatus::FINISHED, attributes: [ + 'configuration_snapshot' => $this->application->fresh()->deploymentConfigurationSnapshot(), + ]); + $scan = deployGateStoredScan([], ['image_id' => GATE_IMAGE_ID, 'deployment_queue_id' => $finished->id]); + $this->queue = deployGatePreviousDeployment('gate-restart', ApplicationDeploymentStatus::IN_PROGRESS); + ScanImage::shouldRun()->never(); + $job = deployGateJob(['restart_only' => true, 'saved_outputs' => new Collection(['local_image_found' => str('sha256:aaaa')])]); + $job->stopAtStart = false; + $job->outputs = ['docker image inspect' => GATE_IMAGE_ID.' ', 'docker images -q' => 'sha256:aaaa']; + + deployGateCall($job, 'just_restart'); + + expect(test()->queue->fresh()->status)->toBe(ApplicationDeploymentStatus::FINISHED->value) + ->and($scan->fresh()->deployment_queue_id)->toBe(test()->queue->id); +}); + +describe('image overrides of a Compose app', function () { + beforeEach(function () { + $this->application->update(['build_pack' => 'dockercompose', 'docker_compose_raw' => Yaml::dump(['services' => ['web' => ['image' => 'nginx:1.30'], 'db' => ['image' => 'postgres:16']]], 10, 2)]); + $this->application->forceFill(['docker_compose_image_overrides' => [ + 'web' => ['from' => 'nginx:1.27', 'to' => 'nginx:1.29'], + 'db' => ['from' => 'postgres:16', 'to' => 'postgres:16.4'], + ]])->save(); + }); + + it('drops an override whose image the compose file no longer names, so it never turns on again', function () { + deployGateImageScan(deployGateJob())->forgetStaleComposeImageOverrides(); + + $application = $this->application->fresh(); + expect($application->docker_compose_image_overrides)->toBe(['db' => ['from' => 'postgres:16', 'to' => 'postgres:16.4']]); + + $application->forceFill(['docker_compose_raw' => Yaml::dump(['services' => ['web' => ['image' => 'nginx:1.27']]], 10, 2)])->save(); + expect($application->fresh()->activeComposeImageOverrides())->toBe([]); + }); + + it('keeps the overrides for a preview deploy and for a compose file that is not valid', function (array $overrides, ?string $raw) { + if ($raw !== null) { + $this->application->forceFill(['docker_compose_raw' => $raw])->save(); + } + $stored = $this->application->fresh()->docker_compose_image_overrides; + + deployGateImageScan(deployGateJob($overrides))->forgetStaleComposeImageOverrides(); + + expect($this->application->fresh()->docker_compose_image_overrides)->toBe($stored); + })->with([ + 'preview' => [['pull_request_id' => 4], null], + 'invalid compose file' => [[], "services: [\n web"], + ]); +}); + +describe('scanned build of a security update', function () { + it('starts the scanned build of a Docker image without a pull of the tag', function () { + ScanImage::shouldRun()->never(); + $this->application->update(['build_pack' => 'dockerimage', 'docker_registry_image_name' => 'nginx', 'docker_registry_image_tag' => '1.27']); + ImageStartPin::put($this->application->fresh(), 'nginx:1.27', GATE_REPO_DIGEST); + $job = deployGateJob(['production_image_name' => 'nginx:1.27']); + + expect(deployGateReachesStart($job))->toBeTrue(); + + $commands = collect($job->commands); + expect($commands->contains(fn (string $command) => str_contains($command, "docker pull 'docker.io/library/nginx@".GATE_REPO_DIGEST."' && ") + && str_contains($command, "docker tag 'docker.io/library/nginx@".GATE_REPO_DIGEST."' 'nginx:1.27'")))->toBeTrue() + ->and($commands->contains(fn (string $command) => preg_match('/docker compose .* pull/', $command) === 1))->toBeFalse() + ->and(ImageStartPin::take($this->application, $this->server->id))->toBe([]); + }); + + it('starts the scanned build on an additional server after the main server took it', function () { + ScanImage::shouldRun()->never(); + $this->application->update(['build_pack' => 'dockerimage', 'docker_registry_image_name' => 'nginx', 'docker_registry_image_tag' => '1.27']); + ImageStartPin::put($this->application->fresh(), 'nginx:1.27', GATE_REPO_DIGEST); + $additional = deployGateServer($this->team, 'additional-server'); + $main = deployGateJob(['production_image_name' => 'nginx:1.27']); + $second = deployGateJob(['production_image_name' => 'nginx:1.27', 'is_this_additional_server' => true, 'server' => $additional, 'mainServer' => $additional]); + $again = deployGateJob(['production_image_name' => 'nginx:1.27']); + $tagged = fn (DeploymentGateTestJob $job) => collect($job->commands)->contains(fn (string $command) => str_contains($command, "docker tag 'docker.io/library/nginx@".GATE_REPO_DIGEST."' 'nginx:1.27'")); + + deployGateImageScan($main)->applyStartPin(); + deployGateImageScan($second)->applyStartPin(); + deployGateImageScan($again)->applyStartPin(); + + expect($tagged($main))->toBeTrue() + ->and($tagged($second))->toBeTrue() + ->and($tagged($again))->toBeFalse(); + }); + + it('lets the gate scan the scanned build instead of pulling the tag again', function () { + deployGatePolicy(); + ScanImage::shouldRun()->once()->withArgs(fn ($server, $resource, $ref) => $ref === 'nginx:1.27')->andReturn(deployGateReturnedScan([])); + $this->application->update(['build_pack' => 'dockerimage', 'docker_registry_image_name' => 'nginx', 'docker_registry_image_tag' => '1.27']); + ImageStartPin::put($this->application->fresh(), 'nginx:1.27', GATE_REPO_DIGEST); + $job = deployGateJob(['production_image_name' => 'nginx:1.27']); + $job->outputs = ['docker image inspect' => GATE_IMAGE_ID.' nginx@'.GATE_REPO_DIGEST.' ']; + + expect(deployGateReachesStart($job))->toBeTrue() + // The pin pulls the tag only as the fallback of a failed digest pull. + ->and(collect($job->commands)->contains(fn (string $command) => str_starts_with($command, "docker pull 'nginx:1.27'")))->toBeFalse(); + }); + + it('does not use the scanned build for a rollback, and keeps it for the next deploy', function () { + ScanImage::shouldRun()->never(); + $this->application->update(['build_pack' => 'dockerimage', 'docker_registry_image_name' => 'nginx', 'docker_registry_image_tag' => '1.27']); + ImageStartPin::put($this->application->fresh(), 'nginx:1.27', GATE_REPO_DIGEST); + $job = deployGateJob(['production_image_name' => 'nginx:1.27', 'rollback' => true]); + + expect(deployGateReachesStart($job))->toBeTrue() + ->and(collect($job->commands)->contains(fn (string $command) => str_contains($command, 'docker tag')))->toBeFalse() + ->and(ImageStartPin::take($this->application))->not->toBe([]); + }); + + it('gives the scanned builds of a Compose app their tags after the pull', function () { + $this->application->update(['build_pack' => 'dockercompose']); + ImageStartPin::put($this->application->fresh(), 'nginx:1.27', GATE_REPO_DIGEST); + $job = deployGateJob(['production_image_name' => 'nginx:1.27']); + + deployGateImageScan($job)->applyComposeStartPins(); + + expect(collect($job->commands)->contains(fn (string $command) => str_contains($command, "docker tag 'docker.io/library/nginx@".GATE_REPO_DIGEST."' 'nginx:1.27'")))->toBeTrue() + ->and(ImageStartPin::take($this->application, $this->server->id))->toBe([]); + }); +}); diff --git a/tests/Feature/ImageScan/DeploymentSurfacesTest.php b/tests/Feature/ImageScan/DeploymentSurfacesTest.php new file mode 100644 index 0000000000..95921be221 --- /dev/null +++ b/tests/Feature/ImageScan/DeploymentSurfacesTest.php @@ -0,0 +1,447 @@ + 'array']); + $this->withoutVite(); + InstanceSettings::forceCreate(['id' => 0, 'is_image_scanning_allowed' => true]); + Queue::fake(); + + $this->team = Team::factory()->create(); + $this->admin = surfacesMember($this->team, 'admin'); + $this->server = Server::factory()->create(['team_id' => $this->team->id]); + $destination = StandaloneDocker::query()->where('server_id', $this->server->id)->firstOrFail(); + $this->project = Project::factory()->create(['team_id' => $this->team->id]); + $this->environment = Environment::factory()->create(['project_id' => $this->project->id]); + $this->application = Application::factory()->create([ + 'environment_id' => $this->environment->id, + 'destination_id' => $destination->id, + 'destination_type' => $destination->getMorphClass(), + ]); +}); + +function surfacesMember(Team $team, string $role = 'member', ?array $access = null): User +{ + $user = User::factory()->create(); + $user->teams()->attach($team, ['role' => $role, 'access' => $access === null ? null : json_encode($access)]); + + return $user->fresh(); +} + +/** + * @return array + */ +function surfacesBlockedResult(): array +{ + return [ + 'blocked' => true, + 'scan_ran' => true, + 'error' => null, + 'count' => 1, + 'headline' => 'Deploy blocked: this image has a security problem that attackers are using.', + 'top' => [['id' => 'CVE-2026-1234', 'severity' => 'critical', 'package' => 'openssl', 'version' => '3.0.13', 'fixed_in' => '3.0.15', 'kev' => false]], + ]; +} + +/** + * @param array $attributes + */ +function surfacesDeployment(array $attributes = []): ApplicationDeploymentQueue +{ + return ApplicationDeploymentQueue::create([ + 'application_id' => test()->application->id, + 'deployment_uuid' => (string) Str::uuid(), + 'server_id' => test()->server->id, + 'server_name' => test()->server->name, + 'destination_id' => test()->application->destination_id, + 'commit' => 'a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2', + 'pull_request_id' => 0, + 'status' => ApplicationDeploymentStatus::FAILED->value, + ...$attributes, + ]); +} + +function surfacesShow(User $user, ApplicationDeploymentQueue $deployment, ?Team $sessionTeam = null): Testable +{ + test()->actingAs($user); + session(['currentTeam' => $sessionTeam ?? test()->team]); + + return Livewire::test(Show::class, [ + 'project_uuid' => test()->project->uuid, + 'environment_uuid' => test()->environment->uuid, + 'application_uuid' => test()->application->uuid, + 'deployment_uuid' => $deployment->deployment_uuid, + ]); +} + +function surfacesPullRequestBody(ApplicationDeploymentQueue $deployment): string +{ + $preview = ApplicationPreview::forceCreate([ + 'application_id' => test()->application->id, + 'pull_request_id' => 7, + 'pull_request_html_url' => 'https://github.com/coollabsio/app/pull/7', + ]); + $job = new ApplicationPullRequestUpdateJob(test()->application->fresh(), $preview, ProcessStatus::ERROR, $deployment->deployment_uuid); + + return (fn (): string => $this->commentBody('app'))->call($job); +} + +describe('status', function () { + it('lists a blocked deployment as Blocked in the history', function () { + surfacesDeployment(['scan_gate_result' => surfacesBlockedResult()]); + $this->actingAs($this->admin); + session(['currentTeam' => $this->team]); + + Livewire::test(Index::class, [ + 'project_uuid' => $this->project->uuid, + 'environment_uuid' => $this->environment->uuid, + 'application_uuid' => $this->application->uuid, + ])->assertSee('Blocked'); + }); + + it('shows a failed deployment without a gate result as Failed', function () { + surfacesDeployment(); + $this->actingAs($this->admin); + session(['currentTeam' => $this->team]); + + Livewire::test(Index::class, [ + 'project_uuid' => $this->project->uuid, + 'environment_uuid' => $this->environment->uuid, + 'application_uuid' => $this->application->uuid, + ])->assertSee('Failed')->assertDontSee('Blocked'); + }); + + it('shows Blocked in the recent deployments of the dashboard', function () { + surfacesDeployment(['scan_gate_result' => surfacesBlockedResult()]); + $this->actingAs($this->admin); + session(['currentTeam' => $this->team]); + + Livewire::test(ActiveDeployments::class)->assertSee('Blocked'); + }); +}); + +describe('log toolbar', function () { + it('offers Deploy anyway to a user with write access', function () { + $deployment = surfacesDeployment(['scan_gate_result' => surfacesBlockedResult()]); + + surfacesShow($this->admin, $deployment) + ->assertSee('Blocked') + ->assertSee('Deploy blocked: this image has a security problem that attackers are using.') + ->assertSee('Open Security') + ->assertSee('Deploy anyway') + ->assertDontSee('Ask someone with write access to deploy anyway.'); + }); + + it('asks a deploy-only user to find someone with write access', function () { + $deployer = surfacesMember($this->team, 'member', [ + ['scope' => 'resource', 'uuid' => $this->application->uuid, 'levels' => ['read', 'deploy']], + ]); + $deployment = surfacesDeployment(['scan_gate_result' => surfacesBlockedResult()]); + + surfacesShow($deployer, $deployment) + ->assertSee('Ask someone with write access to deploy anyway.') + ->assertDontSeeHtml('wire:click="deployAnyway"'); + }); + + it('shows Not scanned with the reason when the scan could not run', function () { + $deployment = surfacesDeployment([ + 'status' => ApplicationDeploymentStatus::FINISHED->value, + 'scan_gate_result' => ['blocked' => false, 'scan_ran' => false, 'error' => 'scanning_off', 'count' => 0, 'headline' => 'Scan skipped: Turn on image scanning on main-server.', 'top' => []], + ]); + + surfacesShow($this->admin, $deployment) + ->assertSee('Not scanned') + ->assertSee('Scan skipped: Turn on image scanning on main-server.') + ->assertSee('Open Security') + ->assertDontSee('Deploy anyway'); + }); + + it('shows the action of the Security page for the blocked image only when a candidate is better', function () { + $this->application->forceFill(['build_pack' => 'dockerimage', 'docker_registry_image_name' => 'postgres', 'docker_registry_image_tag' => '16.3-alpine'])->saveQuietly(); + $deployment = surfacesDeployment(['scan_gate_result' => surfacesBlockedResult()]); + $scan = ImageScan::create([ + 'server_id' => $this->server->id, + 'image_id' => 'sha256:'.str_repeat('a', 64), + 'resource_type' => $this->application->getMorphClass(), + 'resource_id' => $this->application->id, + 'image_ref' => 'postgres:16.3-alpine', + 'deployment_queue_id' => $deployment->id, + 'status' => ImageScanStatus::Completed, + 'findings' => [[ + 'id' => 'CVE-2026-1234', 'aliases' => [], 'severity' => 'critical', 'package' => 'openssl', 'version' => '3.0.13', + 'type' => 'apk', 'artifactId' => 'a1', 'fixedIn' => '3.0.15', 'fixState' => 'fixed', 'kev' => true, + ]], + 'candidates' => ['status' => 'ok', 'checked_at' => now()->toIso8601String(), 'entries' => [ + ['ref' => 'postgres:16.3-alpine', 'kind' => 'same_tag', 'better' => false, 'fixed_ids' => []], + ]], + 'scanned_at' => now(), + ]); + + surfacesShow($this->admin, $deployment)->assertDontSee('Update to'); + + $scan->update(['candidates' => ['status' => 'ok', 'checked_at' => now()->toIso8601String(), 'entries' => [ + ['ref' => 'postgres:16.4-alpine', 'kind' => 'line', 'better' => true, 'fixed_ids' => ['CVE-2026-1234']], + ]]]); + + surfacesShow($this->admin, $deployment)->assertSee('Update to 16.4-alpine.'); + + $scan->update(['candidates' => ['status' => 'ok', 'checked_at' => now()->toIso8601String(), 'entries' => [ + ['ref' => 'postgres:17.0-alpine', 'kind' => 'major', 'better' => true, 'fixed_ids' => ['CVE-2026-1234']], + ]]]); + + surfacesShow($this->admin, $deployment)->assertDontSee('Update to'); + }); +}); + +describe('deploy anyway', function () { + it('queues the same commit with the gate bypassed by the user', function () { + $deployment = surfacesDeployment(['scan_gate_result' => surfacesBlockedResult()]); + + $component = surfacesShow($this->admin, $deployment) + ->call('deployAnyway') + ->assertDispatched('success', 'Deploy started.'); + + $queued = ApplicationDeploymentQueue::query()->whereKeyNot($deployment->id)->sole(); + expect($queued->scan_gate_bypassed_by)->toBe($this->admin->id) + ->and((bool) $queued->force_rebuild)->toBeFalse() + ->and((bool) $queued->rollback)->toBeFalse() + ->and($queued->commit)->toBe($deployment->commit) + ->and($queued->pull_request_id)->toBe(0); + $component->assertRedirect(route('project.application.deployment.show', [ + 'project_uuid' => $this->project->uuid, + 'environment_uuid' => $this->environment->uuid, + 'application_uuid' => $this->application->uuid, + 'deployment_uuid' => $queued->deployment_uuid, + ])); + }); + + it('queues on the server and destination of the blocked deployment and pins the blocked image', function () { + $this->application->update(['build_pack' => 'dockerimage', 'docker_registry_image_name' => 'nginx', 'docker_registry_image_tag' => 'latest']); + $additional = Server::factory()->create(['team_id' => $this->team->id]); + $additionalDestination = StandaloneDocker::query()->where('server_id', $additional->id)->firstOrFail(); + $this->application->additional_servers()->attach($additional->id, ['standalone_docker_id' => $additionalDestination->id]); + $digest = 'nginx@sha256:'.str_repeat('b', 64); + $deployment = surfacesDeployment([ + 'server_id' => $additional->id, + 'server_name' => $additional->name, + 'destination_id' => $additionalDestination->id, + 'only_this_server' => true, + 'scan_gate_result' => [...surfacesBlockedResult(), 'repo_digest' => $digest], + ]); + + surfacesShow($this->admin, $deployment)->call('deployAnyway')->assertDispatched('success', 'Deploy started.'); + + $queued = ApplicationDeploymentQueue::query()->whereKeyNot($deployment->id)->sole(); + expect($queued->server_id)->toBe($additional->id) + ->and((int) $queued->destination_id)->toBe($additionalDestination->id) + ->and((bool) $queued->only_this_server)->toBeTrue() + ->and($queued->scan_gate_result)->toBe(['repo_digest' => $digest]) + ->and($queued->isImageScanBlocked())->toBeFalse() + ->and($queued->isImageScanSkipped())->toBeFalse(); + }); + + it('does not pin a built image or queue on a server that is not one of the application', function () { + $otherServer = Server::factory()->create(['team_id' => Team::factory()->create()->id]); + $deployment = surfacesDeployment([ + 'server_id' => $otherServer->id, + 'scan_gate_result' => [...surfacesBlockedResult(), 'repo_digest' => 'app@sha256:'.str_repeat('b', 64)], + ]); + + surfacesShow($this->admin, $deployment)->call('deployAnyway')->assertDispatched('success', 'Deploy started.'); + + $queued = ApplicationDeploymentQueue::query()->whereKeyNot($deployment->id)->sole(); + expect($queued->server_id)->toBe($this->server->id) + ->and($queued->scan_gate_result)->toBeNull(); + }); + + it('refuses a deployment that was not blocked', function () { + $deployment = surfacesDeployment(); + + surfacesShow($this->admin, $deployment) + ->call('deployAnyway') + ->assertDispatched('error', 'This deployment was not blocked.'); + + expect(ApplicationDeploymentQueue::query()->count())->toBe(1); + }); + + it('refuses a blocked deployment that is not the latest one', function () { + $deployment = surfacesDeployment(['scan_gate_result' => surfacesBlockedResult()]); + surfacesDeployment(['status' => ApplicationDeploymentStatus::FINISHED->value]); + + surfacesShow($this->admin, $deployment) + ->call('deployAnyway') + ->assertDispatched('error', 'A newer deployment exists.'); + + expect(ApplicationDeploymentQueue::query()->count())->toBe(2); + }); + + it('forbids a user with a deploy-only grant', function () { + $deployer = surfacesMember($this->team, 'member', [ + ['scope' => 'resource', 'uuid' => $this->application->uuid, 'levels' => ['read', 'deploy']], + ]); + $deployment = surfacesDeployment(['scan_gate_result' => surfacesBlockedResult()]); + + surfacesShow($deployer, $deployment)->call('deployAnyway')->assertForbidden(); + + expect(ApplicationDeploymentQueue::query()->count())->toBe(1); + }); + + it('authorizes against the team of the application after the session switched teams', function () { + $deployer = surfacesMember($this->team, 'member', [ + ['scope' => 'resource', 'uuid' => $this->application->uuid, 'levels' => ['read', 'deploy']], + ]); + $ownTeam = Team::factory()->create(); + $deployer->teams()->attach($ownTeam, ['role' => 'owner']); + $deployment = surfacesDeployment(['scan_gate_result' => surfacesBlockedResult()]); + + $component = surfacesShow($deployer, $deployment); + session(['currentTeam' => $ownTeam]); + $component->call('deployAnyway')->assertForbidden(); + + $writer = surfacesMember($this->team, 'admin'); + $writer->teams()->attach($ownTeam, ['role' => 'member']); + $component = surfacesShow($writer, $deployment); + session(['currentTeam' => $ownTeam]); + $component->call('deployAnyway')->assertDispatched('success', 'Deploy started.'); + + expect(ApplicationDeploymentQueue::query()->whereNotNull('scan_gate_bypassed_by')->sole()->scan_gate_bypassed_by)->toBe($writer->id); + }); +}); + +describe('topbar warning', function () { + it('shows the gate status when the latest deployment was blocked', function () { + $deployment = surfacesDeployment(['scan_gate_result' => surfacesBlockedResult()]); + $this->actingAs($this->admin); + session(['currentTeam' => $this->team]); + + Livewire::test(Heading::class, ['application' => $this->application]) + ->assertSeeHtml('data-image-scan-gate') + ->assertSee('Deploy blocked: this image has a security problem that attackers are using.') + ->assertSee($deployment->deployment_uuid); + }); + + it('hides the gate status when a newer deployment exists', function () { + surfacesDeployment(['scan_gate_result' => surfacesBlockedResult()]); + surfacesDeployment(['status' => ApplicationDeploymentStatus::FINISHED->value]); + $this->actingAs($this->admin); + session(['currentTeam' => $this->team]); + + Livewire::test(Heading::class, ['application' => $this->application]) + ->assertDontSeeHtml('data-image-scan-gate'); + }); + + it('reads no deployment when the instance does not allow scanning', function () { + surfacesDeployment(['scan_gate_result' => surfacesBlockedResult()]); + InstanceSettings::query()->whereKey(0)->update(['is_image_scanning_allowed' => false]); + Once::flush(); + $this->actingAs($this->admin); + session(['currentTeam' => $this->team]); + $component = Livewire::test(Heading::class, ['application' => $this->application]); + + DB::enableQueryLog(); + $gate = $component->instance()->imageScanGate(); + $queries = collect(DB::getQueryLog())->pluck('query'); + DB::disableQueryLog(); + + expect($gate)->toBeNull() + ->and($queries->filter(fn (string $query) => str_contains($query, 'application_deployment_queues')))->toBeEmpty(); + $component->assertDontSeeHtml('data-image-scan-gate'); + }); + + it('reads no deployment when the app scan is off', function () { + surfacesDeployment(['scan_gate_result' => surfacesBlockedResult()]); + $this->application->settings->forceFill(['is_image_scan_enabled' => false])->saveQuietly(); + $this->actingAs($this->admin); + session(['currentTeam' => $this->team]); + $component = Livewire::test(Heading::class, ['application' => $this->application->fresh()]); + + DB::enableQueryLog(); + $gate = $component->instance()->imageScanGate(); + $queries = collect(DB::getQueryLog())->pluck('query'); + DB::disableQueryLog(); + + expect($gate)->toBeNull() + ->and($queries->filter(fn (string $query) => str_contains($query, 'application_deployment_queues')))->toBeEmpty(); + $component->assertDontSeeHtml('data-image-scan-gate'); + }); + + it('ignores blocked preview deployments', function () { + surfacesDeployment(['pull_request_id' => 7, 'scan_gate_result' => surfacesBlockedResult()]); + $this->actingAs($this->admin); + session(['currentTeam' => $this->team]); + + Livewire::test(Heading::class, ['application' => $this->application]) + ->assertDontSeeHtml('data-image-scan-gate'); + }); +}); + +describe('pull request comment', function () { + it('says blocked with a link and no vulnerability IDs or counts', function () { + $deployment = surfacesDeployment(['pull_request_id' => 7, 'scan_gate_result' => surfacesBlockedResult()]); + + $body = surfacesPullRequestBody($deployment); + + expect($body)->toContain('Image scan: blocked.') + ->toContain('[Open in Coolify]('.base_url().'/project/'.$this->project->uuid.'/environment/'.$this->environment->uuid.'/application/'.$this->application->uuid.'/deployment/'.$deployment->deployment_uuid.')') + ->not->toContain('CVE-') + ->not->toContain('critical'); + }); + + it('says passed when the scan ran and did not block', function () { + $deployment = surfacesDeployment([ + 'pull_request_id' => 7, + 'status' => ApplicationDeploymentStatus::FINISHED->value, + 'scan_gate_result' => ['blocked' => false, 'scan_ran' => true, 'error' => null, 'count' => 0, 'headline' => '', 'top' => []], + ]); + + expect(surfacesPullRequestBody($deployment))->toContain('Image scan: passed.'); + }); + + it('has no image scan line when the gate did not run', function () { + $deployment = surfacesDeployment(['pull_request_id' => 7]); + + expect(surfacesPullRequestBody($deployment))->not->toContain('Image scan'); + }); + + it('has no image scan line when the scan could not run and the deploy went on', function () { + $deployment = surfacesDeployment([ + 'pull_request_id' => 7, + 'status' => ApplicationDeploymentStatus::FINISHED->value, + 'scan_gate_result' => ['blocked' => false, 'scan_ran' => false, 'error' => 'timeout', 'count' => 0, 'headline' => 'Scan skipped: The scan took too long and was stopped.', 'top' => []], + ]); + + expect(surfacesPullRequestBody($deployment))->not->toContain('Image scan'); + }); +}); diff --git a/tests/Feature/ImageScan/FleetVulnerabilitiesTest.php b/tests/Feature/ImageScan/FleetVulnerabilitiesTest.php new file mode 100644 index 0000000000..761832d113 --- /dev/null +++ b/tests/Feature/ImageScan/FleetVulnerabilitiesTest.php @@ -0,0 +1,890 @@ + 'array']); + $this->withoutVite(); + InstanceSettings::forceCreate(['id' => 0, 'is_image_scanning_allowed' => true]); + Queue::fake(); + + $this->team = Team::factory()->create(['show_boarding' => false]); + $this->admin = fleetMember($this->team, 'admin'); + $this->server = fleetServer($this->team, 'scanning-server'); + $this->destination = StandaloneDocker::query()->where('server_id', $this->server->id)->firstOrFail(); + $this->project = Project::factory()->create(['team_id' => $this->team->id, 'name' => 'Shop']); + $this->environment = Environment::query()->firstOrCreate(['project_id' => $this->project->id, 'name' => 'production']); +}); + +function fleetMember(Team $team, string $role = 'member', ?array $access = null): User +{ + $user = User::factory()->create(); + $user->teams()->attach($team, ['role' => $role, 'access' => $access === null ? null : json_encode($access)]); + + return $user->fresh(); +} + +function fleetServer(Team $team, string $name, bool $scanning = true): Server +{ + $server = Server::factory()->create(['team_id' => $team->id, 'name' => $name]); + $server->settings->forceFill(['is_image_scan_enabled' => $scanning])->saveQuietly(); + + return $server; +} + +/** + * @param array $attributes + */ +function fleetApplication(string $name, ?Environment $environment = null, ?StandaloneDocker $destination = null, array $attributes = []): Application +{ + $destination ??= test()->destination; + + return Application::factory()->create([ + 'name' => $name, + 'environment_id' => ($environment ?? test()->environment)->id, + 'destination_id' => $destination->id, + 'destination_type' => $destination->getMorphClass(), + 'build_pack' => 'dockerimage', + 'docker_registry_image_name' => 'nginx', + 'docker_registry_image_tag' => '1.27', + ...$attributes, + ]); +} + +/** + * @param array $overrides + * @return array + */ +function fleetFinding(array $overrides = []): array +{ + return [ + 'id' => 'CVE-2024-0001', 'aliases' => [], 'severity' => 'high', 'package' => 'openssl', 'version' => '3.0.1', + 'type' => 'apk', 'artifactId' => 'a1', 'fixedIn' => '3.0.2', 'fixState' => 'fixed', 'kev' => false, + 'epss' => 0.2, 'risk' => 1.0, 'summary' => 'A bug.', 'layer' => 'app', + ...$overrides, + ]; +} + +/** + * A completed scan of a third-party image with the stored security status of `$status` (update_now, update_available, + * no_fix, check_needed, ok). An update fixes the findings of update_now and update_available: the same image with the + * tag of `update` (default 1.28). + * + * @param array $attributes Scan attributes, plus `server` and `update`. + */ +function fleetScan(Model $resource, string $status = 'ok', array $attributes = []): ImageScan +{ + $server = $attributes['server'] ?? test()->server; + $imageRef = $attributes['image_ref'] ?? 'nginx:1.27'; + $update = str($imageRef)->beforeLast(':').':'.($attributes['update'] ?? '1.28'); + unset($attributes['server'], $attributes['update']); + $fixes = in_array($status, ['update_now', 'update_available'], true); + + $scan = ImageScan::factory()->create([ + 'server_id' => $server->id, + 'resource_type' => $resource->getMorphClass(), + 'resource_id' => $resource->getKey(), + 'image_ref' => $imageRef, + 'builds_image' => false, + 'findings' => match ($status) { + 'update_now' => [fleetFinding(['kev' => true])], + 'update_available' => [fleetFinding(['epss' => null])], + 'no_fix' => [fleetFinding(['kev' => true, 'fixedIn' => null, 'fixState' => 'not-fixed'])], + default => [], + }, + 'candidates' => [ + 'status' => $fixes ? 'ok' : 'no_better', 'checked_at' => now()->toIso8601String(), 'error' => null, 'tags_truncated' => false, + 'entries' => $fixes ? [['kind' => 'line', 'ref' => $update, 'better' => true, 'fixed_ids' => ['CVE-2024-0001'], 'eol' => null]] : [], + ], + 'summary' => ['truncated' => false, 'eol_distro' => null], + 'grype_db_built_at' => $status === 'check_needed' ? now()->subDays(6) : now()->subDay(), + 'scanned_at' => now(), + ...$attributes, + ]); + $scan->setRelation('resource', $resource); + ResourceScans::refreshSummaries(collect([$scan])); + + return $scan->fresh(); +} + +/** + * Livewire embeds list data with @js(), which writes quotes as \u0022. + */ +function fleetJsPayload(string $json): string +{ + return str_replace('"', '\u0022', $json); +} + +function fleetPage(User $user, ?Team $team = null, array $query = []): Testable +{ + test()->actingAs($user); + session(['currentTeam' => $team ?? test()->team]); + + return Livewire::withQueryParams($query)->test(FleetVulnerabilities::class); +} + +describe('page', function () { + it('opens for a member as the Security page and sorts by status, then name', function () { + fleetScan(fleetApplication('b-ok-app'), 'ok'); + fleetScan(fleetApplication('a-ok-app'), 'ok'); + fleetApplication('never-app'); + fleetScan(fleetApplication('check-app'), 'check_needed'); + fleetScan(fleetApplication('nofix-app'), 'no_fix'); + fleetScan(fleetApplication('available-app'), 'update_available'); + fleetScan(fleetApplication('now-app'), 'update_now'); + + $this->actingAs(fleetMember($this->team)); + session(['currentTeam' => $this->team]); + $this->get(route('vulnerabilities', ['showNotChecked' => 1])) + ->assertOk() + ->assertSee('Security | Coolify', false) + ->assertSeeInOrder([ + 'now-app', 'Update now', 'available-app', 'Security update', 'nofix-app', 'No fix yet', + 'check-app', 'Check needed', 'a-ok-app', 'No action needed', 'b-ok-app', 'No action needed', + 'never-app', 'Not checked', + ]); + }); + + it('hides the resources that were not scanned until the user shows them', function () { + fleetScan(fleetApplication('now-app'), 'update_now'); + fleetApplication('never-app'); + + fleetPage($this->admin) + ->assertSee('now-app') + ->assertDontSee('never-app') + ->assertSeeInOrder(['1 resource on 1 server was not scanned, so Coolify cannot tell.', 'Show them']) + ->call('toggleNotChecked') + ->assertSet('showNotChecked', true) + ->assertSeeInOrder(['now-app', 'never-app', 'Not checked', 'Hide them']) + ->call('toggleNotChecked') + ->assertDontSee('never-app') + ->call('toggleStatus', 'not_checked') + ->assertSee('never-app') + ->assertDontSee('now-app') + ->assertDontSee('Show them'); + }); + + it('says that no fix is released yet in the Action column of a no fix resource', function () { + fleetScan(fleetApplication('nofix-app'), 'no_fix'); + + fleetPage($this->admin)->assertSeeInOrder(['nofix-app', 'No fix yet', TeamVulnerabilities::NO_FIX_ACTION]); + }); + + it('shows the primary action as a link to the Security card of the resource, without buttons', function () { + $now = fleetApplication('now-app'); + fleetScan($now, 'update_now'); + $noFix = fleetApplication('nofix-app'); + fleetScan($noFix, 'no_fix'); + + $page = fleetPage($this->admin) + ->assertSee('Update to 1.28.') + ->assertSeeHtml('href="'.TeamVulnerabilities::vulnerabilitiesUrl($now).'#security-section"') + ->assertDontSeeHtml('