mirror of
https://github.com/coollabsio/coolify.git
synced 2026-11-06 13:10:37 -04:00
chore: remove unused components and markup-only tests
- Delete Blade components, component classes, Livewire components and views that nothing uses after the redesign. - Delete tests that only compare Blade/CSS source text; keep the source checks for authorization gates, stable wire:key values and escaping. - Update stale tests to the current UI and behaviour. - Update DESIGN.md to the current navigation and layout. - Add a lesson: test behaviour, not source markup. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
a32082b03f
commit
a79ee909c6
@@ -17,6 +17,11 @@
|
||||
- Do not add billing restrictions, live reconciliation, or fallback behavior unless the request includes them.
|
||||
- Treat implementation constraints as details. Do not expand a requested team-level control into a more complex policy model.
|
||||
|
||||
## Test behavior, not source markup
|
||||
- Do not write tests that read Blade or CSS files as text to assert layout, classes, or copy; they break on every redesign and miss real failures.
|
||||
- Render the component (`Livewire::test()`, an HTTP request) and assert the result, including authorization; use a few `tests/v4/Browser` tests with screenshots for visual flows.
|
||||
- A source-text check is acceptable only for a correctness or security rule that rendering cannot prove easily, such as stable `wire:key` values or no raw user output.
|
||||
|
||||
## Keep dynamic Livewire identities stable
|
||||
- In dynamic lists, key components and actions with immutable record identities, not counts, indexes, or array positions.
|
||||
- Use targeted refresh events. Do not refresh a parent and a child that the parent can remove or hide during the same operation.
|
||||
|
||||
@@ -20,10 +20,9 @@ compact divided list, not legacy green check SVGs or fixed-width status rows.
|
||||
> interactions while changing layout and presentation.
|
||||
> - Add or update tests when a UI change affects behavior. Follow the testing
|
||||
> requirements in `AGENTS.md`.
|
||||
> - Validate Blade with `docker exec coolify php artisan view:cache`, then clear
|
||||
> it with `docker exec coolify php artisan view:clear`.
|
||||
> - Build frontend assets in the Vite container with
|
||||
> `docker exec coolify-vite npm run build`.
|
||||
> - Validate Blade with `./scripts/dev exec php artisan view:cache`, then clear
|
||||
> it with `./scripts/dev exec php artisan view:clear`.
|
||||
> - Build frontend assets with `npm run build`.
|
||||
> - Use existing components before adding another styling abstraction.
|
||||
|
||||
---
|
||||
@@ -42,7 +41,7 @@ The interface is compact and product-focused:
|
||||
- the Coolify purple brand accent in light mode;
|
||||
- the readable Coolify yellow accent in dark mode;
|
||||
- solid active-item fills (neutral black/white opacity), not accent gradients;
|
||||
active state is the left accent rail plus a flat selected surface;
|
||||
active state is a flat selected surface with no accent rail;
|
||||
- sentence-case labels and headings;
|
||||
- never use the em dash (`—`) in UI copy. Prefer a period, colon, comma, or
|
||||
ASCII hyphen (`-`) for empty cells and separators.
|
||||
@@ -59,17 +58,18 @@ and focus-visible controls retain the accent ring alongside the depth.
|
||||
Movement and depth-shadow changes transition over 80ms; color transitions keep
|
||||
the shared 120ms duration.
|
||||
Standard button labels use `capitalize`, giving each word an initial capital.
|
||||
Highlighted buttons mix the accent equally with black for a pronounced bottom
|
||||
edge, so custom theme colors produce a matching edge instead of a generic one.
|
||||
Dark mode matches the depth edge of neutral buttons to their regular border
|
||||
color. Highlighted buttons keep their dark, color-matched accent edge.
|
||||
Highlighted buttons use `--color-coollabs-300` for their bottom edge; the
|
||||
custom theme derives it from its bright color mixed with black, so custom
|
||||
colors keep a matching edge. Dark mode matches the depth edge of neutral
|
||||
buttons to their regular border color (`rgb(255 255 255 / 0.08)`).
|
||||
|
||||
---
|
||||
|
||||
## 2. Development and cascade notes
|
||||
|
||||
PHP runs in the `coolify` container. The development app is normally available
|
||||
at `http://localhost:8000`, with Vite on port `5173`.
|
||||
PHP runs in this branch's Coolify container (`./scripts/dev exec …`). The main
|
||||
checkout serves the app at `http://localhost:8000` with Vite on `5173`;
|
||||
worktrees use the port block printed by `./scripts/dev urls`.
|
||||
|
||||
`resources/css/app.css` still contains unlayered global element rules for
|
||||
headings, labels, and tables. Tailwind utilities are layered, so the
|
||||
@@ -89,8 +89,8 @@ Important consequences:
|
||||
- add shared surface overrides to the unlayered block instead of stacking
|
||||
`!important` utilities;
|
||||
- listbox panels require ancestors with `overflow: visible`;
|
||||
- anchored cards use `scroll-margin-top: 7rem` to clear both fixed navigation
|
||||
layers;
|
||||
- anchored cards use `scroll-margin-top: 7rem` to clear the fixed topbar and
|
||||
the page's top padding;
|
||||
- modal shells reuse the layer-card classes but keep content-width sizing on
|
||||
desktop;
|
||||
- Alpine code inside quoted Blade attributes must not introduce conflicting
|
||||
@@ -104,11 +104,11 @@ The surface ladder is defined in `resources/css/app.css`.
|
||||
|
||||
| Token | Light | Dark | Use |
|
||||
|---|---|---|---|
|
||||
| `--coollabs-canvas` | 97% off-white | 10% neutral | page canvas (kept below card fills so cards lift) |
|
||||
| `--coollabs-elevated` | 98% neutral | 15% neutral | shells and card headers |
|
||||
| `--coollabs-base` | white | 17% neutral | nested card bodies |
|
||||
| `--coollabs-recessed` | 96% neutral | 20% neutral | inputs and listboxes |
|
||||
| `--coollabs-fill` | 92.2% neutral | 26.9% neutral | dividers and passive fills |
|
||||
| `--coollabs-canvas` | 97% off-white | 14.48% neutral | page canvas (kept below card fills so cards lift) |
|
||||
| `--coollabs-elevated` | 98% neutral | 20.02% neutral | shells and card headers |
|
||||
| `--coollabs-base` | white | 22.64% neutral | nested card bodies |
|
||||
| `--coollabs-recessed` | 96% neutral | 25.2% neutral | inputs and listboxes |
|
||||
| `--coollabs-fill` | 92.2% neutral | 29.31% neutral | dividers and passive fills |
|
||||
| `--coollabs-line` | translucent dark | 32% neutral | control borders |
|
||||
| `--coollabs-hairline` | 85.5% neutral | 32% neutral | shell rings (crisp enough to read as a card edge, ~1.5:1) |
|
||||
| `--coollabs-subtle` | 50% neutral | 70.8% neutral | labels and muted titles (light darkened for WCAG AA 4.5:1) |
|
||||
@@ -137,17 +137,17 @@ The app shell is three distinct surface layers, not one flat color. Chrome
|
||||
lifts, content is the base, cards lift off the content:
|
||||
|
||||
- **Content canvas** is the base layer: `bg-app` in dark (deepest,
|
||||
`--color-app` `#0a0a0b`), `bg-gray-50` in light. The `<main>` content area
|
||||
and page body use it.
|
||||
`--color-app` `oklch(14.48% 0 0)`, sRGB 10), `bg-neutral-50` in light. The
|
||||
`<main>` content area and page body use it.
|
||||
- **Sidebar and topbar chrome** use `bg-panel` in dark (`--color-panel`
|
||||
`#141418`, a clear step lighter than the content canvas) and `bg-white` in
|
||||
light, so the chrome reads as a separate panel from the content.
|
||||
`oklch(19.13% 0 0)`, sRGB 20, a clear step lighter than the content canvas)
|
||||
and `bg-white` in light, so the chrome reads as a separate panel from the content.
|
||||
|
||||
Dark surface tokens are hex, not oklch. oklch lightness compresses toward pure
|
||||
black below ~15% (oklch(10%) renders as sRGB 3, oklch(15%) as sRGB 11), so oklch
|
||||
values there give no visible step between layers. The dark ladder is
|
||||
`--color-app` 10, `--coollabs-elevated` 22, `--coollabs-base` 28,
|
||||
`--coollabs-recessed` 34 (sRGB), which reads as distinct surfaces.
|
||||
Dark surface tokens are exact oklch equivalents of chosen sRGB steps. oklch
|
||||
lightness compresses toward pure black below ~15%, so do not pick dark values by
|
||||
round oklch percentages. The dark ladder is `--color-app` 10,
|
||||
`--coollabs-elevated` 22, `--coollabs-base` 28, `--coollabs-recessed` 34 (sRGB),
|
||||
which reads as distinct surfaces.
|
||||
|
||||
Temperature: every panel is **pure neutral gray** (r=g=b), one consistent
|
||||
temperature across the sidebar, tables, cards, inputs, dividers, borders, and
|
||||
@@ -171,60 +171,54 @@ color. Row-hover states keep the lighter `dark:hover:bg-white/[0.025]`.
|
||||
|
||||
- Main sidebar groups are compact, use outline Reicons, and keep a 32px row
|
||||
height.
|
||||
- Active sidebar rows are rounded pills (`rounded-md`) with an accent rail on
|
||||
the left plus a solid neutral selected fill (`bg-black/5` light,
|
||||
`bg-white/6` dark). Hover rows use the same radius. Do not use accent-tinted
|
||||
gradients on nav rows; yellow washes look muddy on dark UI.
|
||||
- Nested items use a thin guide line with a visible active segment, not a thick
|
||||
box border.
|
||||
- Active sidebar rows are rounded pills (`rounded-md`) with a solid neutral
|
||||
selected fill (`bg-black/5` light, `bg-white/6` dark) and no accent rail.
|
||||
Hover rows use the same radius. Do not use accent-tinted gradients on nav
|
||||
rows; yellow washes look muddy on dark UI.
|
||||
- Nested items sit behind a thin 1px guide line (`.nav-children`) and use the
|
||||
same selected pill, not a thick box border.
|
||||
- The update badge sits on the version row and uses a tiny fully rounded
|
||||
primary-action pill.
|
||||
|
||||
### Layer-2 navigation
|
||||
### Resource navigation
|
||||
|
||||
Application and server pages use the same fixed second navigation layer
|
||||
directly below the global topbar. Do not keep a large in-flow resource heading
|
||||
or legacy `.navbar-main` tabs on one resource type while using the compact
|
||||
layer-2 bar on another. Active tabs are a light brand fill:
|
||||
|
||||
- purple tint in light mode;
|
||||
- yellow tint in dark mode;
|
||||
- no fully saturated tab background.
|
||||
Application, service, database, and server pages have no second navigation
|
||||
bar; content starts directly below the 48px global topbar. Every route in a
|
||||
resource family (settings pages, backups, logs, terminal, metrics, danger zone)
|
||||
is an entry in its grouped settings sidebar. Do not add a tab row, a large
|
||||
in-flow resource heading, or legacy `.navbar-main` tabs to one resource type.
|
||||
|
||||
Keep route-derived active state in Blade/Livewire. Do not rely only on Alpine
|
||||
state because it can disappear after polling or a Livewire morph.
|
||||
|
||||
The global topbar owns the current resource identity and its compact status
|
||||
badges. Layer 2 owns route tabs, resource links, and contextual action buttons
|
||||
only. If a resource is missing from `x-top-breadcrumb`, extend the global
|
||||
topbar instead of repeating its name or status summary in layer 2. Mobile
|
||||
resource navigation may repeat this context because the desktop global topbar
|
||||
is hidden there.
|
||||
The global topbar owns the current resource identity, its compact status
|
||||
badges, configuration warnings (`#configuration-warning-hud-slot`), and the
|
||||
resource actions (`#resource-action-hud-slot`). If a resource is missing from
|
||||
`x-top-breadcrumb`, extend the global topbar instead of repeating its name or
|
||||
status summary in the page. Below `xl` the resource heading repeats the name,
|
||||
status, and links in-flow because the desktop HUD is hidden there.
|
||||
|
||||
Desktop resource lifecycle actions dock in `#resource-action-hud-slot` and
|
||||
use `<x-resource-heading-overflow>`. Show primary actions (Deploy, Redeploy,
|
||||
Restart, Stop) as sibling header buttons. Collapse that group into an Actions
|
||||
dropdown only when the remaining top-bar width cannot fit them (breadcrumb
|
||||
keeps a 200px floor). Infrequent operations live in a separate Advanced
|
||||
dropdown with the grid icon: force restart / force deploy / force cleanup
|
||||
on services, and Traefik dashboard / refresh proxy status on servers. Place
|
||||
Advanced immediately after Links, or first in the action cluster when there
|
||||
is no Links control. Application Deploy is a dropdown with Deploy and
|
||||
Deploy (without cache). A running service Restart control is a dropdown with
|
||||
Restart current version and Pull latest and restart. Mobile
|
||||
headings keep a full-width Actions dropdown because the desktop HUD is hidden
|
||||
below `xl`. Do not hide primary actions behind a menu on a wide desktop. Links
|
||||
stay a separate dropdown because the URL list is unbounded.
|
||||
Desktop resource actions dock in `#resource-action-hud-slot` (visible from
|
||||
`xl`) as one `<x-split-action>`. The main button is the primary action for the
|
||||
current state (Deploy, Restart, Start, Restart Proxy); the caret opens a menu
|
||||
with the secondary actions: Deploy (without cache) and Restart on applications,
|
||||
Pull latest and restart / Force Restart / Force Deploy / Force Cleanup
|
||||
Containers on services, Refresh Proxy Status on servers. Stop is the last menu
|
||||
item in the error color. Stop, restart, and removal items open the existing
|
||||
confirmation modals. Do not add a separate Advanced dropdown or collapse actions
|
||||
into an overflow menu. Place Links (`x-applications.links`, `x-services.links`)
|
||||
or the server Traefik Dashboard link immediately before the split action; Links
|
||||
stay a separate dropdown because the URL list is unbounded. Below `xl` the same
|
||||
split action renders full width under the in-flow resource name. A resource
|
||||
that cannot deploy yet shows a single Actions dropdown that explains why.
|
||||
|
||||
Only add layer-2 tabs when they represent real sibling routes inside one
|
||||
context. Never repeat main-sidebar destinations such as Dashboard, Projects,
|
||||
Terminal, Servers, Sources, Destinations, or Storage as a second tab row. A
|
||||
single collection page does not need a tab just to fill the bar; keep its
|
||||
primary action in the page header instead. When tabs are useful, their left edge
|
||||
uses the same compact `pl-2` alignment as application navigation rather than
|
||||
the content container's wide horizontal padding.
|
||||
The only fixed tab strip left is `x-dashboard.navbar`, and it renders only when
|
||||
a page has at least two real sibling routes or header actions. Never repeat
|
||||
main-sidebar destinations such as Dashboard, Projects, Terminal, Servers,
|
||||
Sources, Destinations, or Storage as a tab row. A single collection page does
|
||||
not need a tab just to fill the bar; keep its primary action in the page header.
|
||||
|
||||
A layer-2 tab must be active on the page that renders it. A bar whose only tab
|
||||
A tab must be active on the page that renders it. A bar whose only tab
|
||||
points at a different route reads as broken navigation, so project and
|
||||
environment pages (`project.show`, `project.edit`, `project.environment.edit`,
|
||||
`project.clone-me`) carry a plain page header with a 24px title and a 13px
|
||||
@@ -232,11 +226,11 @@ muted summary instead of a bar. The environment identity and the way back to
|
||||
its resources already live in `x-top-breadcrumb`; do not restate them in a
|
||||
sub-header.
|
||||
|
||||
The dashboard is a compact overview, not a metrics wall. Use two full-width
|
||||
sections that follow the projects-page grid pattern: projects first, then
|
||||
servers. Keep one `New` action in the page header and let its modal choose the
|
||||
resource type. Place active deployments above the resource grids as a compact,
|
||||
live-updating table rather than a metric card. Communicate server health with
|
||||
The dashboard is a compact overview, not a metrics wall. It has no page header:
|
||||
live active deployments come first as a compact table, followed by traffic
|
||||
analytics when a server has it enabled, then two full-width sections (Projects,
|
||||
then Servers) that follow the projects-page grid pattern. Each section uses
|
||||
`x-section-heading` linking to its full index. Communicate server health with
|
||||
the shared status badge.
|
||||
|
||||
### Top-level dashboard destinations
|
||||
@@ -258,9 +252,9 @@ cards. Sources, destinations, S3 storage, private keys, and shared-variable
|
||||
scopes use this pattern.
|
||||
|
||||
Top-level settings families such as Team, Notifications, Keys & Tokens, and
|
||||
instance Settings use a compact header followed by a small route-derived tab
|
||||
strip. The active tab uses the same purple-light/yellow-dark tint as resource
|
||||
tabs. Do not nest `<button>` elements inside tab links.
|
||||
instance Settings use their `*settings-layout` component: the same grouped,
|
||||
icon-led settings sidebar as resources, plus a `settings-mobile-header` title
|
||||
below `xl`. Do not nest `<button>` elements inside sidebar links.
|
||||
|
||||
### Route-family consistency
|
||||
|
||||
@@ -270,11 +264,10 @@ its index or most visible route:
|
||||
- index, create, detail, settings, logs, metrics, backup, execution, and danger
|
||||
routes must share the same navigation hierarchy and surface language;
|
||||
- main-sidebar collection routes use the global shell without duplicating those
|
||||
destinations in a layer-2 tab row;
|
||||
- resource detail families use resource identity and status in the global
|
||||
topbar, route tabs and actions in layer 2, and the grouped settings sidebar
|
||||
only for the third level;
|
||||
- create and edit routes stay inside the same layer-2 family instead of
|
||||
destinations in a tab row;
|
||||
- resource detail families use resource identity, status, and actions in the
|
||||
global topbar, and put every sibling route in the grouped settings sidebar;
|
||||
- create and edit routes stay inside the same sidebar family instead of
|
||||
falling back to an isolated legacy page;
|
||||
- reusable partials, empty states, confirmation flows, and row editors must be
|
||||
updated with the page that exposes them;
|
||||
@@ -292,40 +285,37 @@ content below the filter card.
|
||||
|
||||
### Settings workspace
|
||||
|
||||
Application and server configuration pages use the same 210px grouped,
|
||||
icon-led sidebar and a full-width content column. The workspace is capped at
|
||||
1180px, the sidebar becomes sticky at `xl`, and the sidebar label and first
|
||||
content card start on the same visual line. Do not use the legacy
|
||||
`sub-menu-wrapper`, native mobile page selects, or an in-flow row of top-level
|
||||
tabs. Only show nested section anchors when a page has at least four useful
|
||||
sections.
|
||||
Application, service, database, server, and top-level settings pages use the
|
||||
same 210px grouped, icon-led sidebar and a full-width content column. From `xl`
|
||||
the sidebar is a fixed full-height rail below the topbar that tracks the main
|
||||
sidebar width, with its own fill, right hairline, filter input, and scroll. Below
|
||||
`xl` it becomes a wrapped grid of links above the content. Do not use the legacy
|
||||
`sub-menu-wrapper`, native mobile page selects, or a row of top-level tabs. Only
|
||||
show nested section anchors when a page has at least four useful sections.
|
||||
|
||||
The shared workspace grid is:
|
||||
|
||||
```blade
|
||||
<div
|
||||
class="application-settings-workspace mt-8 grid min-w-0 gap-8
|
||||
xl:mt-0 xl:grid-cols-[210px_minmax(0,1fr)] xl:gap-10">
|
||||
<aside class="application-settings-navigation min-w-0 xl:sticky xl:top-26 xl:self-start">
|
||||
...
|
||||
</aside>
|
||||
<div class="min-w-0 xl:mt-3">
|
||||
...
|
||||
<section class="application-settings-workspace w-full max-w-none">
|
||||
<div class="grid min-w-0 gap-8 xl:grid-cols-[210px_minmax(0,1fr)] xl:gap-8">
|
||||
<x-application.configuration-sidebar :application="$application" :current-route="$currentRoute" />
|
||||
<div class="min-w-0">
|
||||
...
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
```
|
||||
|
||||
Instance Settings constrains both `x-settings.navbar` and the workspace to the
|
||||
same `max-w-[1180px]` shell.
|
||||
Instance Settings uses `x-settings.layout` with the same full-width workspace.
|
||||
|
||||
**Page titles (global):** family H1s (`x-dashboard.navbar` with
|
||||
`titleOnDesktop="false"`, the default) hide at **lg+**, the same breakpoint as
|
||||
the desktop shell (main sidebar + fixed layer-2 tabs). Below `lg` the mobile
|
||||
topbar is used and the page title stays visible. Collection indexes (Servers,
|
||||
Projects, …) always keep their H1; stack title above actions on narrow widths
|
||||
so they never overlap. Resource in-flow names only render below `md` (when the
|
||||
fixed resource tab bar is hidden). Fixed layer-2 spacers must be `lg:h-12` to
|
||||
match the bar height. Do not put the H1 beside the settings sidebar.
|
||||
**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, …)
|
||||
always keep their H1; stack title above actions on narrow widths so they never
|
||||
overlap. Application, service, and database names render in-flow below `xl`
|
||||
(the desktop action HUD breakpoint), server names below `lg`, and settings
|
||||
families use `settings-mobile-header` below `xl`. Fixed tab-strip spacers must
|
||||
be `lg:h-12` to match the bar height. Do not put the H1 beside the settings sidebar.
|
||||
|
||||
Standard content stack:
|
||||
|
||||
@@ -761,11 +751,11 @@ Use these as implementation references:
|
||||
|---|---|
|
||||
| Dashboard overview | `resources/views/livewire/dashboard.blade.php` |
|
||||
| Top-level collection cards | `resources/views/livewire/project/index.blade.php`, `resources/views/source/all.blade.php` |
|
||||
| Top-level family tabs | `resources/views/components/team/navbar.blade.php`, `resources/views/components/notification/navbar.blade.php` |
|
||||
| Top-level settings families | `resources/views/components/team/settings-layout.blade.php`, `resources/views/components/notification/settings-layout.blade.php` |
|
||||
| General settings and form anatomy | `resources/views/livewire/project/application/general.blade.php` |
|
||||
| Advanced settings | `resources/views/livewire/project/application/advanced.blade.php` |
|
||||
| Fixed layer-2 resource navigation | `resources/views/livewire/project/application/heading.blade.php`, `resources/views/livewire/server/navbar.blade.php` |
|
||||
| Grouped settings sidebar | `resources/views/livewire/project/application/configuration.blade.php`, `resources/views/components/server/sidebar.blade.php` |
|
||||
| Resource actions in the topbar | `resources/views/livewire/project/application/heading.blade.php`, `resources/views/components/split-action.blade.php`, `resources/views/livewire/server/navbar.blade.php` |
|
||||
| Grouped settings sidebar | `resources/views/components/application/configuration-sidebar.blade.php`, `resources/views/components/server/sidebar.blade.php` |
|
||||
| Dense environment table and footer | `resources/views/livewire/project/shared/environment-variable/all.blade.php` |
|
||||
| Standard table toolbar controls | `resources/views/components/table/*` |
|
||||
| Application metrics charts | `resources/views/livewire/project/shared/metrics.blade.php` |
|
||||
@@ -795,7 +785,7 @@ oversized 200px status numbers.
|
||||
2. Read the current Blade and Livewire class before changing presentation.
|
||||
3. Preserve every existing action, authorization check, loading state, and
|
||||
confirmation.
|
||||
4. Add the correct dual navigation and scoped workspace/form class.
|
||||
4. Add the grouped settings sidebar and scoped workspace/form class.
|
||||
5. Convert meaningful groups to layer cards and use `gap-6`.
|
||||
6. Make the responsive column count match the controls visible in every state.
|
||||
7. Replace native selects and checkbox-style configuration with listboxes.
|
||||
@@ -808,6 +798,6 @@ oversized 200px status numbers.
|
||||
14. Check fixed-nav anchor offsets and responsive stacking.
|
||||
15. Sweep every sibling route for legacy controls and shells.
|
||||
16. Run `git diff --check`.
|
||||
17. Compile Blade views in the `coolify` container.
|
||||
18. Build assets in `coolify-vite`.
|
||||
17. Compile Blade views with `./scripts/dev exec php artisan view:cache`.
|
||||
18. Build assets with `npm run build`.
|
||||
19. Hard-refresh and inspect the family routes in both themes.
|
||||
|
||||
+1
-39
@@ -1704,7 +1704,7 @@ html[data-theme="custom"] textarea:disabled {
|
||||
/* crisp ring + a restrained lift so the card separates from the canvas
|
||||
(shadcn-style), not a heavy floating shadow */
|
||||
box-shadow: 0 0 0 1px var(--coollabs-hairline), 0 1px 2px 0 rgb(0 0 0 / 0.05);
|
||||
/* keep anchored scrolls (scrollIntoView) clear of the fixed topbar + layer-2 nav */
|
||||
/* keep anchored scrolls (scrollIntoView) clear of the fixed topbar and page padding */
|
||||
scroll-margin-top: 7rem;
|
||||
}
|
||||
|
||||
@@ -2120,44 +2120,6 @@ html[data-theme="custom"] textarea:disabled {
|
||||
overflow: visible;
|
||||
}
|
||||
|
||||
.resource-heading-overflow {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.resource-heading-overflow-items {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.125rem;
|
||||
}
|
||||
|
||||
.resource-heading-overflow-items.is-measuring {
|
||||
display: flex !important;
|
||||
visibility: hidden !important;
|
||||
position: absolute !important;
|
||||
inset: auto auto 0 0 !important;
|
||||
flex-direction: row !important;
|
||||
width: max-content !important;
|
||||
height: auto !important;
|
||||
max-width: none !important;
|
||||
max-height: none !important;
|
||||
overflow: visible !important;
|
||||
pointer-events: none !important;
|
||||
padding: 0 !important;
|
||||
border: 0 !important;
|
||||
background: transparent !important;
|
||||
box-shadow: none !important;
|
||||
}
|
||||
|
||||
.resource-heading-overflow.is-collapsed .resource-heading-overflow-items > .button,
|
||||
.resource-heading-overflow.is-collapsed .resource-heading-overflow-items > a.button {
|
||||
width: 100%;
|
||||
justify-content: flex-start;
|
||||
height: auto;
|
||||
min-height: 2rem;
|
||||
padding: 0.375rem 0.625rem;
|
||||
}
|
||||
|
||||
/* Heading action group: buttons + dropdown triggers styled like the tabs */
|
||||
.application-heading-actions .button,
|
||||
.application-heading-actions .app-tab,
|
||||
|
||||
@@ -27,7 +27,7 @@
|
||||
|
||||
@if ($dashboardProjects->isEmpty())
|
||||
<x-empty title="No projects yet"
|
||||
description="Use New to create your first deployment workspace."
|
||||
description="Create your first deployment workspace from Projects."
|
||||
icon-name="projects" size="sm" />
|
||||
@else
|
||||
<div class="grid min-w-0 grid-cols-1 gap-3 sm:grid-cols-2 lg:grid-cols-4">
|
||||
|
||||
@@ -1,29 +1,4 @@
|
||||
<nav wire:poll.10000ms="checkStatus" class="w-full max-w-none pb-4 md:pb-6 lg:pb-0">
|
||||
@php
|
||||
$routeIs = fn (string|array $routes): bool => \Illuminate\Support\Str::is($routes, $activeRouteName);
|
||||
// Settings covers all configuration sub-pages (General, Webhooks, Domains, …),
|
||||
// not only project.application.configuration. Primary tabs that are NOT settings:
|
||||
// backups, console, deployment logs, runtime logs.
|
||||
$isSettingsRoute = $routeIs('project.application.*')
|
||||
&& ! $routeIs([
|
||||
'project.application.backup.*',
|
||||
'project.application.command',
|
||||
'project.application.deployment.*',
|
||||
'project.application.logs',
|
||||
]);
|
||||
$applicationMenuItems = [
|
||||
[
|
||||
'label' => 'Settings',
|
||||
'route' => 'project.application.configuration',
|
||||
'active' => $isSettingsRoute,
|
||||
],
|
||||
];
|
||||
|
||||
$applicationMenuItems = array_values(array_filter(
|
||||
$applicationMenuItems,
|
||||
fn (array $item): bool => $item['visible'] ?? true,
|
||||
));
|
||||
@endphp
|
||||
<div>
|
||||
<div class="mb-3 w-full xl:hidden">
|
||||
<div class="flex min-w-0 flex-col items-start gap-2">
|
||||
|
||||
@@ -1,47 +1,5 @@
|
||||
<nav wire:poll.10000ms="checkStatus" class="w-full max-w-none pb-4 md:pb-6 lg:pb-0">
|
||||
@php
|
||||
$servicePageItems = [
|
||||
[
|
||||
'label' => 'Settings',
|
||||
'route' => 'project.service.configuration',
|
||||
'active' => request()->routeIs('project.service.configuration')
|
||||
|| request()->routeIs('project.service.domains')
|
||||
|| request()->routeIs('project.service.environment-variables')
|
||||
|| request()->routeIs('project.service.storages')
|
||||
|| request()->routeIs('project.service.scheduled-tasks*')
|
||||
|| request()->routeIs('project.service.webhooks')
|
||||
|| request()->routeIs('project.service.resource-operations')
|
||||
|| request()->routeIs('project.service.analytics')
|
||||
|| request()->routeIs('project.service.tags')
|
||||
|| request()->routeIs('project.service.danger')
|
||||
|| request()->routeIs('project.service.index*')
|
||||
|| request()->routeIs('project.service.database.*'),
|
||||
],
|
||||
[
|
||||
'label' => 'Backups',
|
||||
'route' => 'project.service.volume-backups.index',
|
||||
'active' => request()->routeIs('project.service.volume-backups.*'),
|
||||
],
|
||||
[
|
||||
'label' => 'Runtime Logs',
|
||||
'route' => 'project.service.logs',
|
||||
'active' => request()->routeIs('project.service.logs'),
|
||||
'navigate' => false,
|
||||
],
|
||||
[
|
||||
'label' => 'Terminal',
|
||||
'route' => 'project.service.command',
|
||||
'active' => request()->routeIs('project.service.command'),
|
||||
'navigate' => false,
|
||||
'visible' => auth()->user()?->can('canAccessTerminal'),
|
||||
],
|
||||
];
|
||||
|
||||
$servicePageItems = array_values(array_filter(
|
||||
$servicePageItems,
|
||||
fn (array $item): bool => $item['visible'] ?? true,
|
||||
));
|
||||
|
||||
$serviceStatus = str($service->status ?? 'exited');
|
||||
$selectedResourceUuid = data_get($parameters, 'stack_service_uuid');
|
||||
$selectedResource = $selectedResourceUuid
|
||||
|
||||
@@ -1,13 +1,7 @@
|
||||
<?php
|
||||
|
||||
use App\Livewire\Tags\Deployments as TagDeployments;
|
||||
use App\Livewire\Team\AdminView;
|
||||
use App\Models\Application;
|
||||
use App\Models\ApplicationDeploymentQueue;
|
||||
use App\Models\Environment;
|
||||
use App\Models\InstanceSettings;
|
||||
use App\Models\Project;
|
||||
use App\Models\Server;
|
||||
use App\Models\Team;
|
||||
use App\Models\User;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
@@ -46,39 +40,3 @@ test('team admin view checks instance admin access when it renders', function ()
|
||||
|
||||
expect(fn () => (new AdminView)->render())->toThrow(HttpException::class);
|
||||
});
|
||||
|
||||
test('tag deployments include only applications in the current team', function () {
|
||||
$team = Team::factory()->create();
|
||||
$user = User::factory()->create();
|
||||
$team->members()->attach($user->id, ['role' => 'owner']);
|
||||
$this->actingAs($user);
|
||||
session(['currentTeam' => $team]);
|
||||
|
||||
$createDeployment = function (Team $owner, string $name): Application {
|
||||
$server = Server::factory()->create(['team_id' => $owner->id]);
|
||||
$project = Project::factory()->create(['team_id' => $owner->id]);
|
||||
$environment = Environment::factory()->create(['project_id' => $project->id]);
|
||||
$application = Application::factory()->create(['environment_id' => $environment->id]);
|
||||
ApplicationDeploymentQueue::create([
|
||||
'application_id' => $application->id,
|
||||
'application_name' => $name,
|
||||
'deployment_uuid' => fake()->uuid(),
|
||||
'server_id' => $server->id,
|
||||
'server_name' => $server->name,
|
||||
'status' => 'queued',
|
||||
]);
|
||||
|
||||
return $application;
|
||||
};
|
||||
|
||||
$owned = $createDeployment($team, 'Owned deployment');
|
||||
$foreign = $createDeployment(Team::factory()->create(), 'Foreign deployment');
|
||||
|
||||
$component = Livewire::test(TagDeployments::class)
|
||||
->set('resourceIds', [$owned->id, $foreign->id])
|
||||
->call('getDeployments');
|
||||
$names = collect($component->get('deploymentsPerTagPerServer'))->flatten(1)->pluck('application_name');
|
||||
|
||||
expect($names->all())->toContain('Owned deployment')
|
||||
->not->toContain('Foreign deployment');
|
||||
});
|
||||
|
||||
@@ -5,7 +5,7 @@ use App\Models\Server;
|
||||
use App\Models\Team;
|
||||
use App\Models\User;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Livewire\Exceptions\CannotUpdateLockedPropertyException;
|
||||
use Livewire\Features\SupportLockedProperties\CannotUpdateLockedPropertyException;
|
||||
use Livewire\Livewire;
|
||||
use Spatie\Activitylog\Models\Activity;
|
||||
|
||||
|
||||
@@ -109,43 +109,6 @@ test('removes stale Content-Length after injecting the HUD', function () {
|
||||
expect($response->headers->has('Content-Length'))->toBeFalse();
|
||||
});
|
||||
|
||||
test('app shell exposes Server-Timing HUD dock slots in desktop and mobile top bars', function () {
|
||||
$layout = file_get_contents(resource_path('views/layouts/app.blade.php'));
|
||||
|
||||
expect($layout)
|
||||
->toContain('id="server-timing-hud-slot"')
|
||||
->toContain('id="server-timing-hud-slot-mobile"')
|
||||
->toContain('data-server-timing-hud-slot');
|
||||
});
|
||||
|
||||
test('Server-Timing HUD docks into navbar slots and floats only as fallback', function () {
|
||||
$hud = file_get_contents(resource_path('views/components/server-timing-hud.blade.php'));
|
||||
|
||||
expect($hud)
|
||||
->toContain('server-timing-hud-slot')
|
||||
->toContain('server-timing-hud-slot-mobile')
|
||||
->toContain("matchMedia('(min-width: 1024px)')")
|
||||
->toContain('floats bottom-left only if no navbar slot is available')
|
||||
// Both navbar pills stay compact (app ms only); the full breakdown lives in the panel/float fallback.
|
||||
->toContain("root.getAttribute('data-sth-mode') === 'docked'")
|
||||
->toContain('compactSummary')
|
||||
->not->toContain("compactSummary = root.getAttribute('data-sth-mode') === 'docked'\n && root.parentElement");
|
||||
});
|
||||
|
||||
test('Server-Timing HUD follows the application color mode', function () {
|
||||
$hud = file_get_contents(resource_path('views/components/server-timing-hud.blade.php'));
|
||||
|
||||
expect($hud)
|
||||
->toContain('#server-timing-hud {')
|
||||
->toContain('html.dark #server-timing-hud {')
|
||||
->toContain('--sth-background: rgba(255, 255, 255, .96)')
|
||||
->toContain('--sth-background: rgba(16, 16, 16, .96)')
|
||||
->toContain('var(--sth-text)')
|
||||
->toContain('var(--sth-border)')
|
||||
->toContain('[data-sth-log]::-webkit-scrollbar-thumb')
|
||||
->toContain('scrollbar-color: var(--sth-scrollbar-thumb) var(--sth-scrollbar-track)');
|
||||
});
|
||||
|
||||
test('does not inject HUD into non-HTML or fragment responses', function () {
|
||||
Config::set('app.server_timing', true);
|
||||
|
||||
@@ -215,16 +178,3 @@ test('SERVER_TIMING_ENABLED can force server_timing on or off regardless of APP_
|
||||
expect($resolve('0', 'local'))->toBeFalse();
|
||||
expect($resolve('off', 'local'))->toBeFalse();
|
||||
});
|
||||
test('server timing HUD can be toggled from the development command center', function () {
|
||||
$hud = file_get_contents(resource_path('views/components/server-timing-hud.blade.php'));
|
||||
$commandCenter = file_get_contents(resource_path('views/livewire/global-search.blade.php'));
|
||||
|
||||
expect($hud)
|
||||
->toContain('coolify.serverTimingHud.enabled')
|
||||
->toContain('server-timing-hud-visibility-changed')
|
||||
->and($commandCenter)
|
||||
->toContain("app()->environment('local')")
|
||||
->toContain('Toggle Server Timing HUD')
|
||||
->toContain("localStorage.setItem('coolify.serverTimingHud.enabled'")
|
||||
->toContain("window.dispatchEvent(new CustomEvent('server-timing-hud-visibility-changed'))");
|
||||
});
|
||||
|
||||
@@ -42,6 +42,7 @@ test('admin search only shows users with paid subscriptions as active', function
|
||||
'search' => 'inactive@example.com',
|
||||
])
|
||||
->call('submitSearch')
|
||||
->assertSee('No')
|
||||
->assertDontSee('Yes');
|
||||
->assertSee('inactive@example.com')
|
||||
->assertSeeHtml('<span class="truncate">Inactive</span>')
|
||||
->assertDontSeeHtml('<span class="truncate">Active</span>');
|
||||
});
|
||||
|
||||
@@ -66,86 +66,6 @@ it('ignores docker compose one-off job containers in both status sources', funct
|
||||
->toContain("filter_var(\$labels->get('com.docker.compose.oneoff'), FILTER_VALIDATE_BOOLEAN)");
|
||||
});
|
||||
|
||||
it('shows restart limit warnings for every resource family', function () {
|
||||
$previews = file_get_contents(resource_path('views/livewire/project/application/previews.blade.php'));
|
||||
$serviceCard = file_get_contents(resource_path('views/livewire/project/service/resource-card.blade.php'));
|
||||
$applicationStatus = file_get_contents(resource_path('views/livewire/project/application/status.blade.php'));
|
||||
$databaseStatus = file_get_contents(resource_path('views/livewire/project/database/status.blade.php'));
|
||||
|
||||
expect($previews)->toContain('<x-application.restart-limit-warning :application="$preview" />')
|
||||
->and($serviceCard)->toContain('<x-application.restart-limit-warning :application="$resource" />')
|
||||
->and($applicationStatus)->toContain('<x-application.restart-limit-warning :application="$application" />')
|
||||
->and($databaseStatus)->toContain('<x-application.restart-limit-warning :application="$database" />');
|
||||
|
||||
$serviceStatus = file_get_contents(resource_path('views/livewire/project/service/status.blade.php'));
|
||||
$serviceHeading = file_get_contents(resource_path('views/livewire/project/service/heading.blade.php'));
|
||||
expect($serviceStatus)->toContain('<x-application.restart-limit-warning :application="$selectedResource" />')
|
||||
->and($serviceStatus)->toContain('$selectedResource?->status ?? $service->status')
|
||||
->and($serviceHeading)->toContain('<x-application.restart-limit-warning :application="$selectedResource" />')
|
||||
->and($serviceHeading)->toContain('$selectedResource?->status ?? $service->status');
|
||||
});
|
||||
|
||||
it('matches application restart badge layout on mobile resource headings', function () {
|
||||
$applicationHeading = file_get_contents(resource_path('views/livewire/project/application/heading.blade.php'));
|
||||
$databaseHeading = file_get_contents(resource_path('views/livewire/project/database/heading.blade.php'));
|
||||
$serviceHeading = file_get_contents(resource_path('views/livewire/project/service/heading.blade.php'));
|
||||
|
||||
foreach ([$applicationHeading, $databaseHeading, $serviceHeading] as $heading) {
|
||||
expect($heading)
|
||||
->toContain('class="relative flex w-full min-w-0 items-center gap-2"')
|
||||
->toContain('class="flex w-full flex-wrap gap-1"');
|
||||
}
|
||||
});
|
||||
|
||||
it('shows database restart limits in shared resource listings', function () {
|
||||
$resourceIndex = file_get_contents(app_path('Livewire/Project/Resource/Index.php'));
|
||||
$serverResources = file_get_contents(resource_path('views/livewire/server/resources.blade.php'));
|
||||
$destination = file_get_contents(resource_path('views/livewire/project/shared/destination.blade.php'));
|
||||
|
||||
expect($resourceIndex)
|
||||
->toContain("method_exists(\$item, 'stoppedAfterRestartLimit')")
|
||||
->not->toContain("\$type === 'application' && \$item->stoppedAfterRestartLimit()")
|
||||
->and($serverResources)
|
||||
->toContain('<x-application.restart-limit-warning :application="$resource" />')
|
||||
->and($destination)
|
||||
->toContain('<x-application.restart-limit-warning :application="$resource" />');
|
||||
});
|
||||
|
||||
it('keeps the service resource table readable with horizontal scrolling on mobile', function () {
|
||||
$configuration = file_get_contents(resource_path('views/livewire/project/service/configuration.blade.php'));
|
||||
$resourceCard = file_get_contents(resource_path('views/livewire/project/service/resource-card.blade.php'));
|
||||
|
||||
expect($configuration)
|
||||
->toContain("'overflow-x-auto rounded-xl")
|
||||
->toContain('min-w-[48rem]')
|
||||
->and($resourceCard)
|
||||
->toContain('min-w-[48rem]')
|
||||
->not->toContain('<div class="hidden truncate font-mono');
|
||||
});
|
||||
|
||||
it('opens service resource settings when a table row is clicked', function () {
|
||||
$resourceCard = file_get_contents(resource_path('views/livewire/project/service/resource-card.blade.php'));
|
||||
|
||||
expect($resourceCard)
|
||||
->toContain('x-on:click="openSettings($event)"')
|
||||
->toContain('x-on:keydown.enter="openSettings($event)"')
|
||||
->toContain("closest('a, button')")
|
||||
->toContain('role="link"')
|
||||
->toContain('tabindex="0"');
|
||||
});
|
||||
|
||||
it('uses selected service resource actions instead of parent complex status actions', function () {
|
||||
$heading = file_get_contents(resource_path('views/livewire/project/service/heading.blade.php'));
|
||||
$headingClass = file_get_contents(app_path('Livewire/Project/Service/Heading.php'));
|
||||
|
||||
expect(substr_count($heading, "\$selectedResource && \$selectedResource->container_present !== false && \$selectedResourceStatus->startsWith('exited')"))->toBe(2)
|
||||
->and($heading)
|
||||
->toContain('Remove container')
|
||||
->toContain('removeSelectedResourceContainer')
|
||||
->and($headingClass)
|
||||
->toContain('public function removeSelectedResourceContainer(): void');
|
||||
});
|
||||
|
||||
it('imports the application model used when claiming a restart limit', function () {
|
||||
$statusAction = file_get_contents(app_path('Actions/Docker/GetContainersStatus.php'));
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
use App\Models\Application;
|
||||
use App\Models\Environment;
|
||||
use App\Models\InstanceSettings;
|
||||
use App\Models\Project;
|
||||
use App\Models\Server;
|
||||
use App\Models\StandaloneDocker;
|
||||
@@ -13,6 +14,8 @@ use Visus\Cuid2\Cuid2;
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
beforeEach(function () {
|
||||
InstanceSettings::forceCreate(['id' => 0]);
|
||||
|
||||
$this->team = Team::factory()->create();
|
||||
$this->user = User::factory()->create();
|
||||
$this->team->members()->attach($this->user->id, ['role' => 'owner']);
|
||||
|
||||
@@ -262,7 +262,7 @@ describe('POST /api/v1/databases/{uuid}/backups', function () {
|
||||
$backup = ScheduledDatabaseBackup::where('uuid', $response->json('uuid'))->first();
|
||||
expect($backup)->not->toBeNull();
|
||||
expect($backup->s3_storage_id)->toBe($this->s3Storage->id);
|
||||
expect($backup->save_s3)->toBeTrue();
|
||||
expect($backup->save_s3)->toBeTruthy();
|
||||
expect($backup->team_id)->toBe($this->team->id);
|
||||
});
|
||||
|
||||
@@ -310,7 +310,7 @@ describe('PATCH /api/v1/databases/{uuid}/backups/{scheduled_backup_uuid}', funct
|
||||
$response->assertStatus(200);
|
||||
$backup->refresh();
|
||||
expect($backup->s3_storage_id)->toBe($this->s3Storage->id);
|
||||
expect($backup->save_s3)->toBeTrue();
|
||||
expect($backup->save_s3)->toBeTruthy();
|
||||
});
|
||||
|
||||
test('rejects s3_storage_uuid from another team on update', function () {
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
use App\Models\Application;
|
||||
use App\Models\Environment;
|
||||
use App\Models\InstanceSettings;
|
||||
use App\Models\Project;
|
||||
use App\Models\Server;
|
||||
use App\Models\StandaloneDocker;
|
||||
@@ -12,6 +13,8 @@ use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
beforeEach(function () {
|
||||
InstanceSettings::forceCreate(['id' => 0, 'is_api_enabled' => true]);
|
||||
|
||||
$this->team = Team::factory()->create();
|
||||
$this->user = User::factory()->create();
|
||||
$this->team->members()->attach($this->user->id, ['role' => 'owner']);
|
||||
@@ -45,7 +48,7 @@ test('returns domains for own team application via uuid query param', function (
|
||||
->getJson("/api/v1/servers/{$this->server->uuid}/domains?uuid={$application->uuid}");
|
||||
|
||||
$response->assertOk();
|
||||
$response->assertJsonFragment(['my-app.example.com']);
|
||||
$response->assertExactJson(['https://my-app.example.com']);
|
||||
});
|
||||
|
||||
test('returns 404 when application uuid belongs to another team', function () {
|
||||
@@ -87,7 +90,7 @@ test('returns 404 when server uuid belongs to another team', function () {
|
||||
|
||||
$otherServer = Server::factory()->create(['team_id' => $otherTeam->id]);
|
||||
|
||||
$response = $this->withHeaders(authHeaders())
|
||||
$response = $this->withHeaders(domainApiAuthHeaders())
|
||||
->getJson("/api/v1/servers/{$otherServer->uuid}/domains");
|
||||
|
||||
$response->assertNotFound();
|
||||
@@ -112,7 +115,7 @@ test('only returns domains for applications on the specified server', function (
|
||||
'destination_type' => $otherDestination->getMorphClass(),
|
||||
]);
|
||||
|
||||
$response = $this->withHeaders(authHeaders())
|
||||
$response = $this->withHeaders(domainApiAuthHeaders())
|
||||
->getJson("/api/v1/servers/{$this->server->uuid}/domains");
|
||||
|
||||
$response->assertOk();
|
||||
|
||||
@@ -5,6 +5,7 @@ use App\Models\InstanceSettings;
|
||||
use App\Models\PrivateKey;
|
||||
use App\Models\Team;
|
||||
use App\Models\User;
|
||||
use App\Rules\SafeExternalUrl;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Support\Facades\Http;
|
||||
|
||||
@@ -460,6 +461,12 @@ describe('GitHub app API url normalization', function () {
|
||||
'is_public' => false,
|
||||
]);
|
||||
|
||||
// The GHE host is fictional, so resolve it to a public IP instead of hitting real DNS.
|
||||
Http::macro('GitSource', fn (string $url) => Http::withOptions(SafeExternalUrl::httpClientOptions(
|
||||
$url,
|
||||
resolver: fn (string $host): array => ['93.184.216.34'],
|
||||
)));
|
||||
|
||||
Http::preventStrayRequests();
|
||||
Http::fake([
|
||||
'https://api.octocorp.ghe.com/zen' => Http::response('Keep it logically awesome.', 200, [
|
||||
|
||||
@@ -17,7 +17,7 @@ uses(RefreshDatabase::class);
|
||||
|
||||
beforeEach(function () {
|
||||
// ApiAllowed middleware requires InstanceSettings with id=0
|
||||
InstanceSettings::create(['id' => 0, 'is_api_enabled' => true]);
|
||||
InstanceSettings::forceCreate(['id' => 0, 'is_api_enabled' => true]);
|
||||
|
||||
$this->team = Team::factory()->create();
|
||||
$this->user = User::factory()->create();
|
||||
|
||||
@@ -12,7 +12,7 @@ use Illuminate\Support\Facades\Queue;
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
beforeEach(function () {
|
||||
InstanceSettings::updateOrCreate(['id' => 0], ['is_api_enabled' => true]);
|
||||
InstanceSettings::forceCreate(['id' => 0, 'is_api_enabled' => true]);
|
||||
|
||||
$this->team = Team::factory()->create();
|
||||
$this->user = User::factory()->create();
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
<?php
|
||||
|
||||
use App\Livewire\Security\ApiTokens;
|
||||
use App\Models\InstanceSettings;
|
||||
use App\Models\Team;
|
||||
use App\Models\User;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
@@ -9,15 +10,20 @@ use Livewire\Livewire;
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
beforeEach(function () {
|
||||
InstanceSettings::forceCreate(['id' => 0, 'is_api_enabled' => true]);
|
||||
|
||||
$this->team = Team::factory()->create();
|
||||
$this->user = User::factory()->create();
|
||||
$this->team->members()->attach($this->user->id, ['role' => 'owner']);
|
||||
|
||||
session(['currentTeam' => $this->team]);
|
||||
$this->actingAs($this->user);
|
||||
});
|
||||
|
||||
describe('token creation with expiration', function () {
|
||||
beforeEach(function () {
|
||||
$this->actingAs($this->user);
|
||||
});
|
||||
|
||||
test('livewire component stores expires_at when expiresInDays set', function () {
|
||||
Livewire::test(ApiTokens::class)
|
||||
->set('description', 'test-token')
|
||||
@@ -30,8 +36,8 @@ describe('token creation with expiration', function () {
|
||||
|
||||
expect($token)->not->toBeNull()
|
||||
->and($token->expires_at)->not->toBeNull()
|
||||
->and($token->expires_at->diffInDays(now()))->toBeGreaterThanOrEqual(6)
|
||||
->and($token->expires_at->diffInDays(now()))->toBeLessThanOrEqual(7);
|
||||
->and(now()->diffInDays($token->expires_at))->toBeGreaterThanOrEqual(6)
|
||||
->and(now()->diffInDays($token->expires_at))->toBeLessThanOrEqual(7);
|
||||
});
|
||||
|
||||
test('livewire component stores null expires_at when expiresInDays null (Never)', function () {
|
||||
@@ -54,7 +60,21 @@ describe('token creation with expiration', function () {
|
||||
->set('expiresInDays', 42)
|
||||
->set('permissions', ['read'])
|
||||
->call('addNewToken')
|
||||
->assertHasErrors('expiresInDays');
|
||||
->assertDispatched('error');
|
||||
|
||||
expect($this->user->tokens()->count())->toBe(0);
|
||||
});
|
||||
|
||||
test('livewire component keeps Never (null) after an intermediate live update', function () {
|
||||
Livewire::test(ApiTokens::class)
|
||||
->set('description', 'never-token')
|
||||
->set('expiresInDays', '')
|
||||
->set('permissions', ['read', 'deploy'])
|
||||
->set('permissions', ['read'])
|
||||
->call('addNewToken')
|
||||
->assertHasNoErrors();
|
||||
|
||||
expect($this->user->tokens()->latest()->first()->expires_at)->toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -36,7 +36,7 @@ describe('Application Model Buildpack Cleanup', function () {
|
||||
expect($application->dockerfile)->toBeNull();
|
||||
expect($application->dockerfile_location)->toBeNull();
|
||||
expect($application->dockerfile_target_build)->toBeNull();
|
||||
expect($application->custom_healthcheck_found)->toBeFalse();
|
||||
expect((bool) $application->custom_healthcheck_found)->toBeFalse();
|
||||
});
|
||||
|
||||
test('model clears dockerfile fields when build_pack changes from dockerfile to static', function () {
|
||||
@@ -61,7 +61,7 @@ describe('Application Model Buildpack Cleanup', function () {
|
||||
expect($application->dockerfile)->toBeNull();
|
||||
expect($application->dockerfile_location)->toBeNull();
|
||||
expect($application->dockerfile_target_build)->toBeNull();
|
||||
expect($application->custom_healthcheck_found)->toBeFalse();
|
||||
expect((bool) $application->custom_healthcheck_found)->toBeFalse();
|
||||
});
|
||||
|
||||
test('model clears dockercompose fields when build_pack changes from dockercompose to nixpacks', function () {
|
||||
@@ -142,7 +142,7 @@ describe('Application Model Buildpack Cleanup', function () {
|
||||
expect($application->dockerfile)->toBeNull();
|
||||
expect($application->dockerfile_location)->toBeNull();
|
||||
expect($application->dockerfile_target_build)->toBeNull();
|
||||
expect($application->custom_healthcheck_found)->toBeFalse();
|
||||
expect((bool) $application->custom_healthcheck_found)->toBeFalse();
|
||||
});
|
||||
|
||||
test('model clears dockercompose fields when build_pack changes from dockercompose to railpack', function () {
|
||||
@@ -283,6 +283,6 @@ describe('Application Model Buildpack Cleanup', function () {
|
||||
expect($application->build_pack)->toBe('dockerfile');
|
||||
expect($application->dockerfile)->toBe('FROM alpine:latest');
|
||||
expect($application->dockerfile_location)->toBe('/Dockerfile');
|
||||
expect($application->custom_healthcheck_found)->toBeTrue();
|
||||
expect((bool) $application->custom_healthcheck_found)->toBeTrue();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -3,7 +3,10 @@
|
||||
use App\Livewire\Project\Application\General;
|
||||
use App\Models\Application;
|
||||
use App\Models\Environment;
|
||||
use App\Models\InstanceSettings;
|
||||
use App\Models\Project;
|
||||
use App\Models\Server;
|
||||
use App\Models\StandaloneDocker;
|
||||
use App\Models\Team;
|
||||
use App\Models\User;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
@@ -24,6 +27,13 @@ beforeEach(function () {
|
||||
// Create project and environment
|
||||
$this->project = Project::factory()->create(['team_id' => $this->team->id]);
|
||||
$this->environment = Environment::factory()->create(['project_id' => $this->project->id]);
|
||||
|
||||
InstanceSettings::unguarded(function () {
|
||||
InstanceSettings::updateOrCreate(['id' => 0], []);
|
||||
});
|
||||
$this->server = Server::factory()->create(['team_id' => $this->team->id]);
|
||||
$this->destination = StandaloneDocker::where('server_id', $this->server->id)->first()
|
||||
?? StandaloneDocker::factory()->create(['server_id' => $this->server->id, 'network' => 'coolify-test']);
|
||||
});
|
||||
|
||||
describe('Buildpack Switching Cleanup', function () {
|
||||
@@ -31,6 +41,8 @@ describe('Buildpack Switching Cleanup', function () {
|
||||
// Create an application with dockerfile buildpack and dockerfile content
|
||||
$application = Application::factory()->create([
|
||||
'environment_id' => $this->environment->id,
|
||||
'destination_id' => $this->destination->id,
|
||||
'destination_type' => StandaloneDocker::class,
|
||||
'build_pack' => 'dockerfile',
|
||||
'dockerfile' => 'FROM node:18\nHEALTHCHECK CMD curl -f http://localhost/ || exit 1',
|
||||
'dockerfile_location' => '/Dockerfile',
|
||||
@@ -39,10 +51,9 @@ describe('Buildpack Switching Cleanup', function () {
|
||||
]);
|
||||
|
||||
// Switch to nixpacks buildpack
|
||||
Livewire::test(General::class, ['application' => $application])
|
||||
Livewire::test(General::class, ['application' => $application->fresh()])
|
||||
->assertSuccessful()
|
||||
->set('buildPack', 'nixpacks')
|
||||
->call('updatedBuildPack');
|
||||
->set('buildPack', 'nixpacks');
|
||||
|
||||
// Verify dockerfile fields were cleared
|
||||
$application->refresh();
|
||||
@@ -50,12 +61,14 @@ describe('Buildpack Switching Cleanup', function () {
|
||||
expect($application->dockerfile)->toBeNull();
|
||||
expect($application->dockerfile_location)->toBeNull();
|
||||
expect($application->dockerfile_target_build)->toBeNull();
|
||||
expect($application->custom_healthcheck_found)->toBeFalse();
|
||||
expect((bool) $application->custom_healthcheck_found)->toBeFalse();
|
||||
});
|
||||
|
||||
test('clears dockerfile fields when switching from dockerfile to static', function () {
|
||||
$application = Application::factory()->create([
|
||||
'environment_id' => $this->environment->id,
|
||||
'destination_id' => $this->destination->id,
|
||||
'destination_type' => StandaloneDocker::class,
|
||||
'build_pack' => 'dockerfile',
|
||||
'dockerfile' => 'FROM nginx:alpine',
|
||||
'dockerfile_location' => '/custom.Dockerfile',
|
||||
@@ -63,30 +76,30 @@ describe('Buildpack Switching Cleanup', function () {
|
||||
'custom_healthcheck_found' => true,
|
||||
]);
|
||||
|
||||
Livewire::test(General::class, ['application' => $application])
|
||||
Livewire::test(General::class, ['application' => $application->fresh()])
|
||||
->assertSuccessful()
|
||||
->set('buildPack', 'static')
|
||||
->call('updatedBuildPack');
|
||||
->set('buildPack', 'static');
|
||||
|
||||
$application->refresh();
|
||||
expect($application->build_pack)->toBe('static');
|
||||
expect($application->dockerfile)->toBeNull();
|
||||
expect($application->dockerfile_location)->toBeNull();
|
||||
expect($application->dockerfile_target_build)->toBeNull();
|
||||
expect($application->custom_healthcheck_found)->toBeFalse();
|
||||
expect((bool) $application->custom_healthcheck_found)->toBeFalse();
|
||||
});
|
||||
|
||||
test('does not clear dockerfile fields when switching to dockerfile', function () {
|
||||
$application = Application::factory()->create([
|
||||
'environment_id' => $this->environment->id,
|
||||
'destination_id' => $this->destination->id,
|
||||
'destination_type' => StandaloneDocker::class,
|
||||
'build_pack' => 'nixpacks',
|
||||
'dockerfile' => null,
|
||||
]);
|
||||
|
||||
Livewire::test(General::class, ['application' => $application])
|
||||
Livewire::test(General::class, ['application' => $application->fresh()])
|
||||
->assertSuccessful()
|
||||
->set('buildPack', 'dockerfile')
|
||||
->call('updatedBuildPack');
|
||||
->set('buildPack', 'dockerfile');
|
||||
|
||||
// When switching TO dockerfile, fields remain as they were
|
||||
$application->refresh();
|
||||
@@ -96,15 +109,16 @@ describe('Buildpack Switching Cleanup', function () {
|
||||
test('does not affect fields when switching between non-dockerfile buildpacks', function () {
|
||||
$application = Application::factory()->create([
|
||||
'environment_id' => $this->environment->id,
|
||||
'destination_id' => $this->destination->id,
|
||||
'destination_type' => StandaloneDocker::class,
|
||||
'build_pack' => 'nixpacks',
|
||||
'dockerfile' => null,
|
||||
'dockerfile_location' => null,
|
||||
]);
|
||||
|
||||
Livewire::test(General::class, ['application' => $application])
|
||||
Livewire::test(General::class, ['application' => $application->fresh()])
|
||||
->assertSuccessful()
|
||||
->set('buildPack', 'static')
|
||||
->call('updatedBuildPack');
|
||||
->set('buildPack', 'static');
|
||||
|
||||
$application->refresh();
|
||||
expect($application->build_pack)->toBe('static');
|
||||
@@ -114,6 +128,8 @@ describe('Buildpack Switching Cleanup', function () {
|
||||
test('clears dockerfile fields when switching from dockerfile to railpack', function () {
|
||||
$application = Application::factory()->create([
|
||||
'environment_id' => $this->environment->id,
|
||||
'destination_id' => $this->destination->id,
|
||||
'destination_type' => StandaloneDocker::class,
|
||||
'build_pack' => 'dockerfile',
|
||||
'dockerfile' => 'FROM node:18',
|
||||
'dockerfile_location' => '/Dockerfile',
|
||||
@@ -121,37 +137,37 @@ describe('Buildpack Switching Cleanup', function () {
|
||||
'custom_healthcheck_found' => true,
|
||||
]);
|
||||
|
||||
Livewire::test(General::class, ['application' => $application])
|
||||
Livewire::test(General::class, ['application' => $application->fresh()])
|
||||
->assertSuccessful()
|
||||
->set('buildPack', 'railpack')
|
||||
->call('updatedBuildPack');
|
||||
->set('buildPack', 'railpack');
|
||||
|
||||
$application->refresh();
|
||||
expect($application->build_pack)->toBe('railpack');
|
||||
expect($application->dockerfile)->toBeNull();
|
||||
expect($application->dockerfile_location)->toBeNull();
|
||||
expect($application->dockerfile_target_build)->toBeNull();
|
||||
expect($application->custom_healthcheck_found)->toBeFalse();
|
||||
expect((bool) $application->custom_healthcheck_found)->toBeFalse();
|
||||
});
|
||||
|
||||
test('clears dockerfile fields when switching from dockerfile to dockercompose', function () {
|
||||
$application = Application::factory()->create([
|
||||
'environment_id' => $this->environment->id,
|
||||
'destination_id' => $this->destination->id,
|
||||
'destination_type' => StandaloneDocker::class,
|
||||
'build_pack' => 'dockerfile',
|
||||
'dockerfile' => 'FROM alpine:latest',
|
||||
'dockerfile_location' => '/docker/Dockerfile',
|
||||
'custom_healthcheck_found' => true,
|
||||
]);
|
||||
|
||||
Livewire::test(General::class, ['application' => $application])
|
||||
Livewire::test(General::class, ['application' => $application->fresh()])
|
||||
->assertSuccessful()
|
||||
->set('buildPack', 'dockercompose')
|
||||
->call('updatedBuildPack');
|
||||
->set('buildPack', 'dockercompose');
|
||||
|
||||
$application->refresh();
|
||||
expect($application->build_pack)->toBe('dockercompose');
|
||||
expect($application->dockerfile)->toBeNull();
|
||||
expect($application->dockerfile_location)->toBeNull();
|
||||
expect($application->custom_healthcheck_found)->toBeFalse();
|
||||
expect((bool) $application->custom_healthcheck_found)->toBeFalse();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -334,8 +334,8 @@ describe('GitHub Source Change Component', function () {
|
||||
->assertSet('webhook_endpoint', 'http://staging.example.com')
|
||||
->assertSet('custom_webhook_endpoint', 'https://staging.example.com')
|
||||
->assertSet('use_custom_webhook_endpoint', true)
|
||||
->assertSee('Use custom webhook endpoint')
|
||||
->assertSee('Selected endpoint')
|
||||
->assertSee('Webhook endpoint')
|
||||
->assertSee('Use a custom endpoint')
|
||||
->assertSee('Custom endpoint')
|
||||
->assertSee('createGithubApp(webhookEndpoint, useCustomWebhookEndpoint, customWebhookEndpoint');
|
||||
});
|
||||
@@ -689,7 +689,7 @@ describe('GitHub Source Change Component', function () {
|
||||
->and($public->isConnected())->toBeTrue();
|
||||
});
|
||||
|
||||
test('shows connected badge and test connection for installed github apps', function () {
|
||||
test('shows test connection for installed github apps', function () {
|
||||
$privateKey = PrivateKey::create([
|
||||
'name' => 'Test Key',
|
||||
'private_key' => validPrivateKey(),
|
||||
@@ -713,8 +713,8 @@ describe('GitHub Source Change Component', function () {
|
||||
->test(Change::class)
|
||||
->assertSuccessful()
|
||||
->assertSet('isConnected', true)
|
||||
->assertSee('Connected')
|
||||
->assertSee('Test Connection');
|
||||
->assertSee('Test connection')
|
||||
->assertSeeHtml('wire:click.prevent="testConnection"');
|
||||
});
|
||||
|
||||
test('testConnection succeeds when github app credentials are valid', function () {
|
||||
@@ -808,6 +808,6 @@ describe('GitHub Source Change Component', function () {
|
||||
->assertSee('Finished GitHub App')
|
||||
->assertSee('Connected')
|
||||
->assertSee('Incomplete GitHub App')
|
||||
->assertSee('Setup required');
|
||||
->assertSee('Setup incomplete');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -109,13 +109,16 @@ test('can update general settings when the application has a wildcard domain', f
|
||||
'destination_id' => $this->destination->id,
|
||||
'destination_type' => StandaloneDocker::class,
|
||||
'build_pack' => 'nixpacks',
|
||||
'fqdn' => 'https://example.com,https://*.example.com',
|
||||
'static_image' => 'nginx:alpine',
|
||||
'base_directory' => '/',
|
||||
'ports_exposes' => '3000',
|
||||
'is_http_basic_auth_enabled' => false,
|
||||
'redirect' => 'no',
|
||||
]);
|
||||
// Legacy wildcard domains can no longer be entered (the model normalizes them away on save),
|
||||
// so seed the stored value directly, as it exists on older installs.
|
||||
Application::query()->whereKey($application->id)->update(['fqdn' => 'https://example.com,https://*.example.com']);
|
||||
$application->refresh();
|
||||
|
||||
Livewire::test(General::class, ['application' => $application])
|
||||
->assertSuccessful()
|
||||
|
||||
@@ -24,33 +24,3 @@ it('updates container presence at application lifecycle boundaries', function ()
|
||||
->and($sentinelStatus)->toContain("'container_present' => true")
|
||||
->and($sentinelStatus)->toContain("'container_present' => false");
|
||||
});
|
||||
|
||||
it('shows accurate destructive actions and the restart warning on mobile', function () {
|
||||
$heading = file_get_contents(resource_path('views/livewire/project/application/heading.blade.php'));
|
||||
$mobileActions = str($heading)
|
||||
->after('id="application-mobile-actions"')
|
||||
->before('<div class="hidden" aria-hidden="true">')
|
||||
->toString();
|
||||
|
||||
expect($heading)->toContain('<x-application.restart-limit-warning :application="$application" />')
|
||||
->and(substr_count($heading, '$application->container_present !== false'))->toBe(2)
|
||||
->and($heading)->toContain("\$application->stoppedAfterRestartLimit() ? 'Retry deployment' : 'Deploy'")
|
||||
->and($heading)->toContain("\$application->stoppedAfterRestartLimit() ? 'Retry deployment (without cache)' : 'Deploy (without cache)'")
|
||||
->and($heading)->toContain('Remove container')
|
||||
->and($mobileActions)->toContain('Deploy (without cache)')
|
||||
->and($mobileActions)->toContain('Remove container')
|
||||
->and(strrpos($mobileActions, 'Remove container'))->toBeGreaterThan(strrpos($mobileActions, 'Deploy (without cache)'));
|
||||
});
|
||||
|
||||
it('shows restart limit reached as a yellow state in environment resource lists', function () {
|
||||
$indexClass = file_get_contents(app_path('Livewire/Project/Resource/Index.php'));
|
||||
$indexView = file_get_contents(resource_path('views/livewire/project/resource/index.blade.php'));
|
||||
|
||||
expect($indexClass)->toContain("'restartLimitReached' => \$type === 'application' && \$item->stoppedAfterRestartLimit()")
|
||||
->and($indexClass)->toContain('? max($item->restart_count ?? 0, $item->max_restart_count ?? 0)')
|
||||
->and($indexClass)->toContain("'maxRestartCount' => \$item->max_restart_count ?? 0")
|
||||
->and($indexView)->toContain("if (item.restartLimitReached) {\n return 'restart-limit';")
|
||||
->and($indexView)->toContain("return 'Restart limit reached';")
|
||||
->and($indexView)->toContain("if (item.restartLimitReached) {\n return 'bg-warning';")
|
||||
->and($indexView)->toContain('x-bind:title="statusTitle(item)"');
|
||||
});
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
use App\Models\Application;
|
||||
use App\Models\Environment;
|
||||
use App\Models\InstanceSettings;
|
||||
use App\Models\Project;
|
||||
use App\Models\Server;
|
||||
use App\Models\StandaloneDocker;
|
||||
@@ -13,6 +14,8 @@ use Illuminate\Support\Str;
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
beforeEach(function () {
|
||||
InstanceSettings::forceCreate(['id' => 0, 'is_api_enabled' => true]);
|
||||
|
||||
$this->team = Team::factory()->create();
|
||||
$this->user = User::factory()->create();
|
||||
$this->team->members()->attach($this->user->id, ['role' => 'owner']);
|
||||
|
||||
@@ -417,6 +417,8 @@ it('manages preview domains and their dns status', function () {
|
||||
Livewire::test(PreviewDomains::class, ['preview' => $preview])
|
||||
->assertSet('domainRows.0.url', 'https://44.example.com')
|
||||
->call('checkDomainDns', 0)
|
||||
->assertSet('domainRows.0.dns_status', 'checking')
|
||||
->call('pollDnsChecks')
|
||||
->assertSet('domainRows.0.dns_status', 'skipped')
|
||||
->set('newDomainParts.host', 'second.example.com')
|
||||
->call('addDomain')
|
||||
@@ -564,7 +566,7 @@ it('notifies when an asynchronous preview dns check finds a mismatch', function
|
||||
|
||||
$component->call('pollDnsChecks')
|
||||
->assertSet('domainRows.0.dns_status', 'failed')
|
||||
->assertDispatched('error', 'DNS is not configured for preview.example.com. Review the required DNS record.');
|
||||
->assertDispatched('error', 'DNS is not configured for preview.example.com. Review the required DNS record. If you changed it recently, DNS propagation can take some time, so please try again later.');
|
||||
});
|
||||
|
||||
it('does not overwrite a completed preview dns result with stale checking state', function () {
|
||||
@@ -1318,6 +1320,8 @@ it('marks dns status as skipped when dns validation is disabled', function () {
|
||||
|
||||
Livewire::test(Domains::class, ['application' => $this->application->fresh()])
|
||||
->call('checkAllDns')
|
||||
->assertSet('domainRows.0.dns_status', 'checking')
|
||||
->call('pollDnsChecks')
|
||||
->assertSet('domainRows.0.dns_status', 'skipped');
|
||||
|
||||
$this->application->refresh();
|
||||
@@ -1477,6 +1481,7 @@ it('persists dns status after checking a domain', function () {
|
||||
|
||||
Livewire::test(Domains::class, ['application' => $this->application->fresh()])
|
||||
->call('checkDomainDns', 0)
|
||||
->call('pollDnsChecks')
|
||||
->assertSet('domainRows.0.dns_status', 'failed');
|
||||
|
||||
$this->application->refresh();
|
||||
@@ -1518,7 +1523,7 @@ it('polls a queued dns check and notifies about a mismatch', function () {
|
||||
|
||||
$component->call('pollDnsChecks')
|
||||
->assertSet('domainRows.0.dns_status', 'failed')
|
||||
->assertDispatched('error', 'DNS is not configured for app.example.com. Review the required DNS record.');
|
||||
->assertDispatched('error', 'DNS is not configured for app.example.com. Review the required DNS record. If you changed it recently, DNS propagation can take some time, so please try again later.');
|
||||
});
|
||||
|
||||
it('does not overwrite a completed queued dns result with stale checking state', function () {
|
||||
@@ -1600,7 +1605,7 @@ it('resolves hostname server addresses to a real ip for dns messages', function
|
||||
->and(filter_var($resolvedIp, FILTER_VALIDATE_IP))->not->toBeFalse()
|
||||
->and($component->get('domainRows.0.expected_ip'))->toBe($resolvedIp);
|
||||
|
||||
$component->call('checkAllDns');
|
||||
$component->call('checkAllDns')->call('pollDnsChecks');
|
||||
|
||||
$message = $component->get('domainRows.0.dns_message');
|
||||
$recordType = dnsRecordTypeForIp($resolvedIp);
|
||||
@@ -1626,7 +1631,8 @@ it('uses short aaaa guidance when the server ip is ipv6', function () {
|
||||
$settings->save();
|
||||
|
||||
$component = Livewire::test(Domains::class, ['application' => $this->application->fresh()])
|
||||
->call('checkDomainDns', 0);
|
||||
->call('checkDomainDns', 0)
|
||||
->call('pollDnsChecks');
|
||||
|
||||
expect($component->get('serverIp'))->toBe('2001:db8::10')
|
||||
->and($component->get('domainRows.0.dns_message'))->toBe('Required DNS record type AAAA pointing to 2001:db8::10');
|
||||
@@ -1649,7 +1655,8 @@ it('uses short a-record guidance for compose applications', function () {
|
||||
$this->server->update(['ip' => '172.16.0.3']);
|
||||
|
||||
$component = Livewire::test(Domains::class, ['application' => $this->application->fresh()])
|
||||
->call('checkDomainDns', 0);
|
||||
->call('checkDomainDns', 0)
|
||||
->call('pollDnsChecks');
|
||||
|
||||
expect($component->get('domainRows.0.dns_message'))->toBe('Required DNS record type A pointing to 172.16.0.3')
|
||||
->and($component->get('domainRows.0.dns_status'))->toBe('failed');
|
||||
@@ -2169,66 +2176,12 @@ it('groups compose domains by service and hides empty services', function () {
|
||||
->assertDontSee('No domains for this service');
|
||||
});
|
||||
|
||||
it('uses the compact service domains layout for compose applications', function () {
|
||||
it('keys compose domain groups by a stable service identity', function () {
|
||||
$view = file_get_contents(resource_path('views/livewire/project/application/domains.blade.php'));
|
||||
|
||||
expect($view)
|
||||
->toContain('application-compose-domain-group-{{ $redirectWireKey }}')
|
||||
->toContain('class="application-settings-section-body mt-1 scroll-mt-28')
|
||||
->toContain('bg-neutral-50 px-4 py-3 dark:border-white/10 dark:bg-white/[0.04]')
|
||||
->toContain('class="data-table-header service-domains-overview-grid"')
|
||||
->toContain('<span>Domain redirect</span>')
|
||||
->toContain('<span>Search indexing</span>')
|
||||
->not->toContain('<span>Last checked</span>')
|
||||
->not->toContain('id="edit-domain-direction"')
|
||||
->toContain('wire:key="application-compose-domain-rows-{{ $redirectWireKey }}"')
|
||||
->toContain('htmlId="application-domain-direction"')
|
||||
->toContain('id="editingRedirect"')
|
||||
->not->toContain("\$isCompose ? 'updateServiceRedirect' : 'updateRedirect'")
|
||||
->not->toContain('title="No domains for this service"');
|
||||
});
|
||||
|
||||
it('uses a compact two row domain card at medium widths', function () {
|
||||
$css = file_get_contents(resource_path('css/app.css'));
|
||||
|
||||
expect($css)
|
||||
->toContain('@container service-domains (max-width: 980px)')
|
||||
->toContain('grid-template-columns: minmax(0, 1fr) auto auto;')
|
||||
->toContain('grid-template-areas: "domain dns actions" "summary summary summary";')
|
||||
->toContain('grid-area: summary;');
|
||||
});
|
||||
|
||||
it('uses concise search indexing headers in application and service domain tables', function () {
|
||||
$applicationView = file_get_contents(resource_path('views/livewire/project/application/domains.blade.php'));
|
||||
$serviceView = file_get_contents(resource_path('views/livewire/project/service/partials/domain-table.blade.php'));
|
||||
|
||||
expect(substr_count($applicationView, '<span>Search indexing</span>'))
|
||||
->toBe(1)
|
||||
->and($serviceView)
|
||||
->toContain('<span>Search indexing</span>');
|
||||
});
|
||||
|
||||
it('shows a form save button at the bottom of application domain settings', function () {
|
||||
$view = file_get_contents(resource_path('views/livewire/project/application/domains.blade.php'));
|
||||
|
||||
expect($view)
|
||||
->not->toContain('Indexing and redirect changes save automatically.')
|
||||
->toContain('data-testid="domain-settings-scroll"')
|
||||
->toContain('data-testid="domain-settings-footer"')
|
||||
->toContain('class="shrink-0 border-t')
|
||||
->toContain('<x-forms.button type="submit" wire:target="updateDomain" isHighlighted>')
|
||||
->toContain('Save')
|
||||
->not->toContain('<x-unsaved-bar action="updateDomain"');
|
||||
});
|
||||
|
||||
it('opens application domain settings from browser data and shows a dns spinner', function () {
|
||||
$view = file_get_contents(resource_path('views/livewire/project/application/partials/domain-row.blade.php'));
|
||||
|
||||
expect($view)
|
||||
->not->toContain('wire:click="startEdit(')
|
||||
->toContain('@click="openEditDomain(')
|
||||
->toContain('<x-loading compact aria-label="Checking DNS"')
|
||||
->not->toContain('<x-loading-on-button wire:loading.delay');
|
||||
->toContain('wire:key="application-compose-domain-rows-{{ $redirectWireKey }}"');
|
||||
});
|
||||
|
||||
it('uses the dns badge as progress for single and all application checks', function (string $action, array $parameters) {
|
||||
@@ -2447,54 +2400,6 @@ it('saves regenerated compose domain drafts for only the selected service', func
|
||||
->and($application->noindexDomains()->all())->toBe(["https://{$generatedHost}/api"]);
|
||||
});
|
||||
|
||||
it('does not render a last checked column in the domains table', function () {
|
||||
$view = file_get_contents(resource_path('views/livewire/project/application/domains.blade.php'));
|
||||
$row = file_get_contents(resource_path('views/livewire/project/application/partials/domain-row.blade.php'));
|
||||
|
||||
expect($view)->not->toContain('<span>Last checked</span>')
|
||||
->and($row)->not->toContain('$checkedAt');
|
||||
});
|
||||
|
||||
it('uses compact labeled domain cards on mobile', function () {
|
||||
$styles = file_get_contents(resource_path('css/app.css'));
|
||||
$row = file_get_contents(resource_path('views/livewire/project/application/partials/domain-row.blade.php'));
|
||||
|
||||
expect($styles)
|
||||
->toContain('@container service-domains (max-width: 980px)')
|
||||
->toContain('.service-domain-detail-label')
|
||||
->toContain('.service-domains-overview-grid')
|
||||
->toContain('@container service-domains (max-width: 600px)')
|
||||
->toContain('.service-domain-mobile-summary')
|
||||
->and($row)
|
||||
->toContain('service-domain-mobile-summary')
|
||||
->toContain('No redirects')
|
||||
->toContain('Noindex');
|
||||
});
|
||||
|
||||
it('uses segmented fields when adding and editing application domains', function () {
|
||||
$view = file_get_contents(resource_path('views/livewire/project/application/domains.blade.php'));
|
||||
$component = file_get_contents(resource_path('views/components/forms/domain-input.blade.php'));
|
||||
|
||||
expect($view)
|
||||
->toContain('<x-forms.domain-input id="newDomainParts"')
|
||||
->toContain('<x-forms.domain-input id="editingDomainParts"')
|
||||
->not->toContain('placeholder="https://app.example.com"')
|
||||
->and($component)
|
||||
->toContain('Protocol')
|
||||
->toContain('Domain')
|
||||
->toContain('Port')
|
||||
->toContain('Path')
|
||||
->toContain('wire:model="{{ $id }}.host"')
|
||||
->toContain('<x-forms.listbox id="{{ $id }}.scheme"')
|
||||
->not->toContain('<select id="{{ $id }}-protocol"')
|
||||
->toContain("['value' => 'https', 'label' => 'https']")
|
||||
->toContain("['value' => 'http', 'label' => 'http']")
|
||||
->toContain('class="mb-1.5 flex h-4 w-full items-center gap-1.5"')
|
||||
->not->toContain('class="mb-1.5 block text-sm font-medium"')
|
||||
->toContain('min="1"')
|
||||
->toContain('max="65535"');
|
||||
});
|
||||
|
||||
it('updates a compose service redirect from the domains table', function () {
|
||||
$this->application->update([
|
||||
'build_pack' => 'dockercompose',
|
||||
@@ -2517,19 +2422,6 @@ it('updates a compose service redirect from the domains table', function () {
|
||||
->and(data_get($domains, 'web.domain'))->toContain('https://www.web.example.com');
|
||||
});
|
||||
|
||||
it('provides client-side search for compose service domains', function () {
|
||||
$view = file_get_contents(resource_path('views/livewire/project/application/domains.blade.php'));
|
||||
|
||||
expect($view)
|
||||
->toContain('x-model="domainSearch"')
|
||||
->toContain('class="ml-auto flex flex-wrap items-center gap-2"')
|
||||
->toContain('<div class="relative shrink-0">')
|
||||
->toContain('placeholder="Search services or domains"')
|
||||
->toContain('x-show="matchesDomainSearch(')
|
||||
->toContain('title="No domains found"')
|
||||
->toContain('hasDomainSearchResults(');
|
||||
});
|
||||
|
||||
it('exposes the domains route in the application configuration menu', function () {
|
||||
$url = route('project.application.domains', [
|
||||
'project_uuid' => $this->project->uuid,
|
||||
|
||||
@@ -74,9 +74,10 @@ test('existing application buildpack selector lists railpack before nixpacks', f
|
||||
|
||||
Livewire::test(General::class, ['application' => $application])
|
||||
->assertSuccessful()
|
||||
// The build strategy listbox renders its options as JSON-encoded Alpine data
|
||||
->assertSeeInOrder([
|
||||
'<option value="railpack">Railpack</option>',
|
||||
'<option value="nixpacks">Nixpacks</option>',
|
||||
'\u0022value\u0022:\u0022railpack\u0022,\u0022label\u0022:\u0022Railpack\u0022',
|
||||
'\u0022value\u0022:\u0022nixpacks\u0022,\u0022label\u0022:\u0022Nixpacks\u0022',
|
||||
], false);
|
||||
});
|
||||
|
||||
|
||||
@@ -30,18 +30,6 @@ beforeEach(function () {
|
||||
session(['currentTeam' => $this->team]);
|
||||
});
|
||||
|
||||
it('uses card-style git source triggers instead of plain form buttons', function () {
|
||||
$view = file_get_contents(resource_path('views/livewire/project/application/source.blade.php'));
|
||||
|
||||
expect($view)
|
||||
->toContain('<x-slot:trigger>')
|
||||
->toContain('label="Current"')
|
||||
->toContain('aria-current="true"')
|
||||
->toContain('<x-git-icon')
|
||||
->toContain('rounded-xl border')
|
||||
->not->toContain('<x-slot:customButton>');
|
||||
});
|
||||
|
||||
it('renders the current source as a selected card and alternatives as switchable cards', function () {
|
||||
$current = GithubApp::create([
|
||||
'name' => 'coolify-laravel-dev-public',
|
||||
|
||||
@@ -68,92 +68,6 @@ beforeEach(function () {
|
||||
'destination_type' => $this->destination->getMorphClass(),
|
||||
'status' => 'running',
|
||||
]);
|
||||
|
||||
$this->routeParams = [
|
||||
'project_uuid' => $this->project->uuid,
|
||||
'environment_uuid' => $this->environment->uuid,
|
||||
'application_uuid' => $this->application->uuid,
|
||||
];
|
||||
});
|
||||
|
||||
/**
|
||||
* Settings tab must carry both the active class and aria-current so CSS
|
||||
* under .application-heading-actions can override the base tab resets.
|
||||
*/
|
||||
function assertSettingsTabActive(string $html): void
|
||||
{
|
||||
expect(preg_match(
|
||||
'/<a[^>]*(?:aria-current="page"[^>]*app-tab-active|app-tab-active[^>]*aria-current="page")[^>]*>\s*Settings\s*<\/a>/s',
|
||||
$html
|
||||
))->toBe(1);
|
||||
|
||||
// Desktop navbar CSS override must exist so active styles are visible.
|
||||
$css = file_get_contents(resource_path('css/app.css'));
|
||||
expect($css)
|
||||
->toContain(".application-heading-actions .app-tab[aria-current='page']")
|
||||
->toContain('.application-heading-actions .app-tab.app-tab-active');
|
||||
}
|
||||
|
||||
it('marks settings tab active on general configuration route', function () {
|
||||
$this->actingAs($this->admin);
|
||||
session(['currentTeam' => $this->team]);
|
||||
|
||||
$html = $this->get(route('project.application.configuration', $this->routeParams))
|
||||
->assertSuccessful()
|
||||
->getContent();
|
||||
|
||||
assertSettingsTabActive($html);
|
||||
});
|
||||
|
||||
it('marks settings tab active on webhooks and other settings sub-routes', function (string $routeName) {
|
||||
$this->actingAs($this->admin);
|
||||
session(['currentTeam' => $this->team]);
|
||||
|
||||
$html = $this->get(route($routeName, $this->routeParams))
|
||||
->assertSuccessful()
|
||||
->getContent();
|
||||
|
||||
assertSettingsTabActive($html);
|
||||
})->with([
|
||||
'webhooks' => 'project.application.webhooks',
|
||||
'domains' => 'project.application.domains',
|
||||
'advanced' => 'project.application.advanced',
|
||||
'environment-variables' => 'project.application.environment-variables',
|
||||
'danger' => 'project.application.danger',
|
||||
]);
|
||||
|
||||
it('does not mark settings tab active on deployment logs', function () {
|
||||
$this->actingAs($this->admin);
|
||||
session(['currentTeam' => $this->team]);
|
||||
|
||||
$html = $this->get(route('project.application.deployment.index', $this->routeParams))
|
||||
->assertSuccessful()
|
||||
->getContent();
|
||||
|
||||
expect($html)->toContain('Deployment');
|
||||
|
||||
expect(preg_match(
|
||||
'/<a[^>]*(?:aria-current="page"[^>]*app-tab-active|app-tab-active[^>]*aria-current="page")[^>]*>\s*Settings\s*<\/a>/s',
|
||||
$html
|
||||
))->toBe(0);
|
||||
|
||||
// Deployment now lives in the Logs section of the settings sidebar.
|
||||
expect(preg_match(
|
||||
'/<a[^>]*menu-item-active[^>]*>.*?Deployment.*?<\/a>/s',
|
||||
$html
|
||||
))->toBe(1);
|
||||
});
|
||||
|
||||
it('syncs activeRouteName from the page route when heading is rendered on webhooks', function () {
|
||||
$this->actingAs($this->admin);
|
||||
session(['currentTeam' => $this->team]);
|
||||
|
||||
$html = $this->get(route('project.application.webhooks', $this->routeParams))
|
||||
->assertSuccessful()
|
||||
->assertSeeLivewire(ApplicationHeading::class)
|
||||
->getContent();
|
||||
|
||||
assertSettingsTabActive($html);
|
||||
});
|
||||
|
||||
it('keeps activeRouteName when request is not an application page route', function () {
|
||||
@@ -178,15 +92,9 @@ it('refreshes the breadcrumb application status after it changes', function () {
|
||||
|
||||
$component
|
||||
->call('refreshStatus')
|
||||
->assertSee('Stopped')
|
||||
->assertSee('Exited')
|
||||
->assertDontSee('Running');
|
||||
|
||||
expect($component->instance()->getListeners())
|
||||
->toHaveKey("echo-private:team.{$this->team->id},ServiceChecked", 'refreshStatus');
|
||||
});
|
||||
|
||||
it('uses app-tab-active utility for resource heading active styles', function () {
|
||||
$utilities = file_get_contents(resource_path('css/utilities.css'));
|
||||
|
||||
expect($utilities)->toContain('@utility app-tab-active');
|
||||
});
|
||||
|
||||
@@ -117,21 +117,6 @@ it('renders preview deployment enablement as a section action', function () {
|
||||
expect($this->application->fresh()->settings->is_preview_deployments_enabled)->toBeTrue();
|
||||
});
|
||||
|
||||
it('renders GitHub pull requests in a modal opened from the preview settings', function () {
|
||||
$view = file_get_contents(resource_path('views/livewire/project/application/previews.blade.php'));
|
||||
$sidebar = file_get_contents(resource_path('views/components/application/configuration-sidebar.blade.php'));
|
||||
|
||||
expect($view)
|
||||
->toContain('<x-modal-input title="Pull requests"')
|
||||
->toContain(':isLarge="true"')
|
||||
->toContain('wire:click="load_prs"')
|
||||
->not->toContain('id="preview-pull-requests-section"')
|
||||
->and(strpos($view, '<x-modal-input title="Pull requests"'))
|
||||
->toBeLessThan(strpos($view, '<livewire:project.application.preview.form'));
|
||||
|
||||
expect($sidebar)->not->toContain("['id' => 'preview-pull-requests-section', 'label' => 'Pull requests']");
|
||||
});
|
||||
|
||||
it('does not show git preview settings for non-git applications', function (string $buildPack, ?string $dockerfile) {
|
||||
$this->application->update(['build_pack' => $buildPack, 'dockerfile' => $dockerfile]);
|
||||
|
||||
@@ -156,10 +141,3 @@ it('denies preview setting changes without application update permission', funct
|
||||
->is_preview_deployments_enabled->toBeFalse()
|
||||
->is_pr_deployments_public_enabled->toBeFalse();
|
||||
})->with([['member', false], ['owner', true]]);
|
||||
|
||||
it('removes preview settings from Advanced including its persistence path', function () {
|
||||
expect(file_get_contents(resource_path('views/livewire/project/application/advanced.blade.php')))
|
||||
->not->toContain('isPreviewDeploymentsEnabled', 'isPrDeploymentsPublicEnabled');
|
||||
expect(file_get_contents(app_path('Livewire/Project/Application/Advanced.php')))
|
||||
->not->toContain('is_preview_deployments_enabled', 'is_pr_deployments_public_enabled');
|
||||
});
|
||||
|
||||
@@ -37,7 +37,7 @@ uZx9iFkCELtxrh31QJ68AAAAEXNhaWxANzZmZjY2ZDJlMmRkAQIDBA==
|
||||
|
||||
describe('Application Source with localhost key (id=0)', function () {
|
||||
test('renders deploy key section when private_key_id is 0', function () {
|
||||
$privateKey = PrivateKey::create([
|
||||
$privateKey = PrivateKey::forceCreate([
|
||||
'id' => 0,
|
||||
'name' => 'localhost',
|
||||
'private_key' => applicationSourceValidPrivateKey(),
|
||||
@@ -52,7 +52,8 @@ describe('Application Source with localhost key (id=0)', function () {
|
||||
Livewire::test(Source::class, ['application' => $application])
|
||||
->assertSuccessful()
|
||||
->assertSet('privateKeyId', 0)
|
||||
->assertSee('Deploy Key');
|
||||
->assertSee('localhost')
|
||||
->assertSee('Deploy key');
|
||||
});
|
||||
|
||||
test('shows no source connected section when private_key_id is null', function () {
|
||||
@@ -64,7 +65,7 @@ describe('Application Source with localhost key (id=0)', function () {
|
||||
Livewire::test(Source::class, ['application' => $application])
|
||||
->assertSuccessful()
|
||||
->assertSet('privateKeyId', null)
|
||||
->assertDontSee('Deploy Key')
|
||||
->assertDontSee('Deploy key')
|
||||
->assertSee('No source connected');
|
||||
});
|
||||
|
||||
|
||||
@@ -87,10 +87,9 @@ it('does not infer the restart limit from an exited existing container', functio
|
||||
])->stoppedAfterRestartLimit())->toBeFalse();
|
||||
});
|
||||
|
||||
it('shows a stopped after restart limit warning in the status badge', function () {
|
||||
$html = view('components.status.index', [
|
||||
'resource' => applicationWithRestartState(),
|
||||
'showRefreshButton' => false,
|
||||
it('shows a stopped after restart limit warning badge', function () {
|
||||
$html = view('components.application.restart-limit-warning', [
|
||||
'application' => applicationWithRestartState(),
|
||||
])->render();
|
||||
|
||||
expect($html)->toContain('Restart limit reached')
|
||||
@@ -99,13 +98,12 @@ it('shows a stopped after restart limit warning in the status badge', function (
|
||||
});
|
||||
|
||||
it('does not show the restart limit warning for a normal manual stop', function () {
|
||||
$html = view('components.status.index', [
|
||||
'resource' => applicationWithRestartState([
|
||||
$html = view('components.application.restart-limit-warning', [
|
||||
'application' => applicationWithRestartState([
|
||||
'restart_count' => 0,
|
||||
'last_restart_type' => null,
|
||||
'restart_limit_reached' => false,
|
||||
]),
|
||||
'showRefreshButton' => false,
|
||||
])->render();
|
||||
|
||||
expect($html)->not->toContain('Restart limit reached');
|
||||
|
||||
@@ -27,18 +27,6 @@ it('allows unauthenticated access to two-factor-challenge page', function () {
|
||||
expect($response->status())->toBeIn([200, 302]);
|
||||
});
|
||||
|
||||
it('uses one mobile-friendly field for authenticator code paste and autofill', function () {
|
||||
$challenge = file_get_contents(resource_path('views/auth/two-factor-challenge.blade.php'));
|
||||
|
||||
expect($challenge)
|
||||
->toContain('name="code"')
|
||||
->toContain('autocomplete="one-time-code"')
|
||||
->toContain('inputmode="numeric"')
|
||||
->toContain('maxlength="6"')
|
||||
->toContain('@input="submitAuthenticatorCode($event)"')
|
||||
->not->toContain('x-for="(digit, index) in digits"');
|
||||
});
|
||||
|
||||
it('includes two-factor-challenge in allowed paths for unsubscribed accounts', function () {
|
||||
$paths = allowedPathsForUnsubscribedAccounts();
|
||||
|
||||
|
||||
@@ -8,7 +8,7 @@ use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
beforeEach(function () {
|
||||
InstanceSettings::updateOrCreate(['id' => 0], ['is_api_enabled' => true]);
|
||||
InstanceSettings::forceCreate(['id' => 0, 'is_api_enabled' => true]);
|
||||
|
||||
$this->team = Team::factory()->create();
|
||||
$this->user = User::factory()->create();
|
||||
|
||||
@@ -316,6 +316,14 @@ test('member does not see terminal link for application', function () {
|
||||
$this->actingAs($this->member);
|
||||
session(['currentTeam' => $this->team]);
|
||||
|
||||
// The terminal link lives in the application settings sidebar since the resource tab bar was removed.
|
||||
$this->blade(
|
||||
'<x-application.configuration-sidebar :application="$application" current-route="project.application.configuration" />',
|
||||
['application' => $this->application]
|
||||
)
|
||||
->assertSee('application-settings-link-general', false)
|
||||
->assertDontSee('application-settings-link-terminal', false);
|
||||
|
||||
Livewire::test(ApplicationHeading::class, ['application' => $this->application])
|
||||
->assertDontSee('Terminal');
|
||||
});
|
||||
@@ -324,8 +332,12 @@ test('admin sees terminal link for application', function () {
|
||||
$this->actingAs($this->admin);
|
||||
session(['currentTeam' => $this->team]);
|
||||
|
||||
Livewire::test(ApplicationHeading::class, ['application' => $this->application])
|
||||
->assertSee('Terminal');
|
||||
// The terminal link lives in the application settings sidebar since the resource tab bar was removed.
|
||||
$this->blade(
|
||||
'<x-application.configuration-sidebar :application="$application" current-route="project.application.configuration" />',
|
||||
['application' => $this->application]
|
||||
)
|
||||
->assertSee('application-settings-link-terminal', false);
|
||||
});
|
||||
|
||||
// --- Cross-team isolation ---
|
||||
|
||||
@@ -31,9 +31,9 @@ function createProjectForTeam(Team $team): void
|
||||
]);
|
||||
}
|
||||
|
||||
function createServerWithKeyForTeam(Team $team): void
|
||||
function createPrivateKeyForTeam(Team $team): int
|
||||
{
|
||||
$keyId = DB::table('private_keys')->insertGetId([
|
||||
return DB::table('private_keys')->insertGetId([
|
||||
'uuid' => (string) Str::uuid(),
|
||||
'name' => 'Test Key',
|
||||
'private_key' => 'test-key',
|
||||
@@ -41,14 +41,19 @@ function createServerWithKeyForTeam(Team $team): void
|
||||
'created_at' => now(),
|
||||
'updated_at' => now(),
|
||||
]);
|
||||
}
|
||||
|
||||
function createServerWithKeyForTeam(Team $team): void
|
||||
{
|
||||
Server::factory()->create([
|
||||
'team_id' => $team->id,
|
||||
'private_key_id' => $keyId,
|
||||
'private_key_id' => createPrivateKeyForTeam($team),
|
||||
]);
|
||||
}
|
||||
|
||||
test('admin sees add project button on dashboard', function () {
|
||||
// The dashboard no longer has a "New Project" button; each project card exposes an
|
||||
// "Add resource" shortcut gated by the createAnyResource gate instead.
|
||||
test('admin sees add resource button on dashboard projects', function () {
|
||||
[$user, $team] = setupDashboardUser('admin');
|
||||
|
||||
$this->actingAs($user);
|
||||
@@ -57,10 +62,11 @@ test('admin sees add project button on dashboard', function () {
|
||||
createProjectForTeam($team);
|
||||
|
||||
Livewire::test(Dashboard::class)
|
||||
->assertSee('New Project');
|
||||
->assertSee('Test Project')
|
||||
->assertSee('Add resource to Test Project');
|
||||
});
|
||||
|
||||
test('member does not see add project button on dashboard', function () {
|
||||
test('member does not see add resource button on dashboard projects', function () {
|
||||
[$user, $team] = setupDashboardUser('member');
|
||||
|
||||
$this->actingAs($user);
|
||||
@@ -69,18 +75,22 @@ test('member does not see add project button on dashboard', function () {
|
||||
createProjectForTeam($team);
|
||||
|
||||
Livewire::test(Dashboard::class)
|
||||
->assertSee('Test Project')
|
||||
->assertDontSee('Add resource to Test Project')
|
||||
->assertDontSee('New Project');
|
||||
});
|
||||
|
||||
// The "New server" button is only rendered in the empty servers state (a key exists, no servers yet).
|
||||
test('admin sees add server button on dashboard', function () {
|
||||
[$user, $team] = setupDashboardUser('admin');
|
||||
|
||||
$this->actingAs($user);
|
||||
session(['currentTeam' => $team]);
|
||||
|
||||
createServerWithKeyForTeam($team);
|
||||
createPrivateKeyForTeam($team);
|
||||
|
||||
Livewire::test(Dashboard::class)
|
||||
->assertSee('No servers yet')
|
||||
->assertSee(route('server.create'));
|
||||
});
|
||||
|
||||
@@ -90,6 +100,19 @@ test('member does not see add server button on dashboard', function () {
|
||||
$this->actingAs($user);
|
||||
session(['currentTeam' => $team]);
|
||||
|
||||
createPrivateKeyForTeam($team);
|
||||
|
||||
Livewire::test(Dashboard::class)
|
||||
->assertSee('No servers yet')
|
||||
->assertDontSee(route('server.create'));
|
||||
});
|
||||
|
||||
test('member does not see add server button on dashboard with existing servers', function () {
|
||||
[$user, $team] = setupDashboardUser('member');
|
||||
|
||||
$this->actingAs($user);
|
||||
session(['currentTeam' => $team]);
|
||||
|
||||
createServerWithKeyForTeam($team);
|
||||
|
||||
Livewire::test(Dashboard::class)
|
||||
|
||||
@@ -55,6 +55,10 @@ test('cross-team user cannot view destination', function () {
|
||||
$this->actingAs($this->userB);
|
||||
session(['currentTeam' => $this->teamB]);
|
||||
|
||||
// Lookups are scoped to the current team, so another team's destination is
|
||||
// treated as missing: the user is redirected and no destination data is loaded.
|
||||
Livewire::test(Show::class, ['destination_uuid' => $this->destination->uuid])
|
||||
->assertStatus(403);
|
||||
->assertRedirect(route('destination.index'))
|
||||
->assertSet('destination', null)
|
||||
->assertDontSee($this->server->ip);
|
||||
});
|
||||
|
||||
@@ -8,7 +8,7 @@ use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
beforeEach(function () {
|
||||
InstanceSettings::create(['id' => 0, 'is_api_enabled' => true]);
|
||||
InstanceSettings::forceCreate(['id' => 0, 'is_api_enabled' => true]);
|
||||
|
||||
$this->team = Team::factory()->create();
|
||||
$this->member = User::factory()->create();
|
||||
@@ -28,65 +28,34 @@ function apiRequest($test, string $token, string $method = 'get', string $url =
|
||||
])->{$method.'Json'}($url);
|
||||
}
|
||||
|
||||
/**
|
||||
* The api.token.team middleware rejects elevated abilities on member tokens
|
||||
* before ApiAbility runs, so REST clients get its generic role message.
|
||||
*/
|
||||
describe('member with legacy elevated token is rejected', function () {
|
||||
test('member with legacy write token gets 403 with descriptive message', function () {
|
||||
test('member with legacy elevated token gets 403', function (array $abilities) {
|
||||
$token = $this->member->createToken('legacy-elevated', $abilities);
|
||||
|
||||
$response = apiRequest($this, $token->plainTextToken);
|
||||
|
||||
$response->assertStatus(403);
|
||||
$response->assertExactJson(['message' => 'Missing required team role.']);
|
||||
})->with([
|
||||
'write' => [['read', 'write']],
|
||||
'deploy' => [['read', 'deploy']],
|
||||
'root' => [['root']],
|
||||
'read:sensitive' => [['read', 'read:sensitive']],
|
||||
'write:sensitive' => [['read', 'write:sensitive']],
|
||||
'multiple' => [['read', 'write', 'deploy', 'read:sensitive']],
|
||||
]);
|
||||
|
||||
test('member with legacy elevated token cannot reach a write endpoint', function () {
|
||||
$token = $this->member->createToken('legacy-write', ['read', 'write']);
|
||||
|
||||
$response = apiRequest($this, $token->plainTextToken);
|
||||
$response = apiRequest($this, $token->plainTextToken, 'patch', '/api/v1/team');
|
||||
|
||||
$response->assertStatus(403);
|
||||
$response->assertJsonFragment([
|
||||
'message' => 'This API token has permissions (write) that exceed your current role as a team member. Members are restricted to read-only API access. Please revoke this token and create a new one with only read permissions.',
|
||||
]);
|
||||
});
|
||||
|
||||
test('member with legacy deploy token gets 403', function () {
|
||||
$token = $this->member->createToken('legacy-deploy', ['read', 'deploy']);
|
||||
|
||||
$response = apiRequest($this, $token->plainTextToken);
|
||||
|
||||
$response->assertStatus(403);
|
||||
$response->assertSee('deploy');
|
||||
$response->assertSee('revoke this token');
|
||||
});
|
||||
|
||||
test('member with legacy root token gets 403', function () {
|
||||
$token = $this->member->createToken('legacy-root', ['root']);
|
||||
|
||||
$response = apiRequest($this, $token->plainTextToken);
|
||||
|
||||
$response->assertStatus(403);
|
||||
$response->assertSee('root');
|
||||
});
|
||||
|
||||
test('member with legacy read:sensitive token gets 403', function () {
|
||||
$token = $this->member->createToken('legacy-sensitive', ['read', 'read:sensitive']);
|
||||
|
||||
$response = apiRequest($this, $token->plainTextToken);
|
||||
|
||||
$response->assertStatus(403);
|
||||
$response->assertSee('read:sensitive');
|
||||
});
|
||||
|
||||
test('member with legacy write:sensitive token gets 403', function () {
|
||||
$token = $this->member->createToken('legacy-ws', ['read', 'write:sensitive']);
|
||||
|
||||
$response = apiRequest($this, $token->plainTextToken);
|
||||
|
||||
$response->assertStatus(403);
|
||||
$response->assertSee('write:sensitive');
|
||||
});
|
||||
|
||||
test('member with multiple disallowed abilities lists them all', function () {
|
||||
$token = $this->member->createToken('legacy-multi', ['read', 'write', 'deploy', 'read:sensitive']);
|
||||
|
||||
$response = apiRequest($this, $token->plainTextToken);
|
||||
|
||||
$response->assertStatus(403);
|
||||
$json = $response->json();
|
||||
expect($json['message'])->toContain('write');
|
||||
expect($json['message'])->toContain('deploy');
|
||||
expect($json['message'])->toContain('read:sensitive');
|
||||
$response->assertExactJson(['message' => 'Missing required team role.']);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -19,7 +19,7 @@ use Livewire\Livewire;
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
beforeEach(function () {
|
||||
InstanceSettings::updateOrCreate(['id' => 0]);
|
||||
InstanceSettings::unguarded(fn () => InstanceSettings::updateOrCreate(['id' => 0], ['id' => 0]));
|
||||
|
||||
$this->team = Team::factory()->create();
|
||||
|
||||
@@ -110,7 +110,7 @@ test('admin sees deploy controls for application', function () {
|
||||
session(['currentTeam' => $this->team]);
|
||||
|
||||
Livewire::test(ApplicationHeading::class, ['application' => $this->application])
|
||||
->assertSee('Redeploy')
|
||||
->assertSee('Deploy')
|
||||
->assertSee('Restart')
|
||||
->assertSee('Stop');
|
||||
});
|
||||
@@ -148,14 +148,25 @@ test('member does not see terminal link for application', function () {
|
||||
|
||||
Livewire::test(ApplicationHeading::class, ['application' => $this->application])
|
||||
->assertDontSee('Terminal');
|
||||
|
||||
// The terminal link lives in the application settings sidebar since the resource tab bar was removed.
|
||||
$this->blade(
|
||||
'<x-application.configuration-sidebar :application="$application" current-route="project.application.configuration" />',
|
||||
['application' => $this->application]
|
||||
)
|
||||
->assertSee('application-settings-link-general', false)
|
||||
->assertDontSee('application-settings-link-terminal', false);
|
||||
});
|
||||
|
||||
test('admin sees terminal link for application', function () {
|
||||
$this->actingAs($this->admin);
|
||||
session(['currentTeam' => $this->team]);
|
||||
|
||||
Livewire::test(ApplicationHeading::class, ['application' => $this->application])
|
||||
->assertSee('Terminal');
|
||||
$this->blade(
|
||||
'<x-application.configuration-sidebar :application="$application" current-route="project.application.configuration" />',
|
||||
['application' => $this->application]
|
||||
)
|
||||
->assertSee('application-settings-link-terminal', false);
|
||||
});
|
||||
|
||||
// --- Database Heading (via page route for rendering, policy checks for actions) ---
|
||||
@@ -233,16 +244,24 @@ test('member does not see terminal link for service', function () {
|
||||
'query' => [],
|
||||
])
|
||||
->assertDontSee('Terminal');
|
||||
|
||||
// The terminal link lives in the service settings sidebar since the resource tab bar was removed.
|
||||
$this->blade(
|
||||
'<x-service.configuration-sidebar :service="$service" current-route="project.service.configuration" />',
|
||||
['service' => $this->service]
|
||||
)
|
||||
->assertSee(route('project.service.configuration', $this->serviceParams), false)
|
||||
->assertDontSee(route('project.service.command', $this->serviceParams), false);
|
||||
});
|
||||
|
||||
test('admin sees terminal link for service', function () {
|
||||
$this->actingAs($this->admin);
|
||||
session(['currentTeam' => $this->team]);
|
||||
|
||||
Livewire::test(ServiceHeading::class, [
|
||||
'service' => $this->service,
|
||||
'parameters' => $this->serviceParams,
|
||||
'query' => [],
|
||||
])
|
||||
$this->blade(
|
||||
'<x-service.configuration-sidebar :service="$service" current-route="project.service.configuration" />',
|
||||
['service' => $this->service]
|
||||
)
|
||||
->assertSee(route('project.service.command', $this->serviceParams), false)
|
||||
->assertSee('Terminal');
|
||||
});
|
||||
|
||||
@@ -48,8 +48,9 @@ test('admin sees add and cleanup buttons on private key page', function () {
|
||||
session(['currentTeam' => $this->team]);
|
||||
|
||||
Livewire::test(PrivateKeyIndex::class)
|
||||
->assertSee('+ Add')
|
||||
->assertSee('Delete unused SSH Keys');
|
||||
->assertSee('New private key')
|
||||
->assertSee('Generate ED25519')
|
||||
->assertSee('Delete unused keys');
|
||||
});
|
||||
|
||||
test('member does not see add or cleanup buttons on private key page', function () {
|
||||
@@ -57,8 +58,10 @@ test('member does not see add or cleanup buttons on private key page', function
|
||||
session(['currentTeam' => $this->team]);
|
||||
|
||||
Livewire::test(PrivateKeyIndex::class)
|
||||
->assertDontSee('+ Add')
|
||||
->assertDontSee('Delete unused SSH Keys');
|
||||
->assertSee('Team SSH Key')
|
||||
->assertDontSee('New private key')
|
||||
->assertDontSee('Generate ED25519')
|
||||
->assertDontSee('Delete unused keys');
|
||||
});
|
||||
|
||||
test('member can view private key names on index page', function () {
|
||||
@@ -87,7 +90,7 @@ test('admin sees invite section on team members page', function () {
|
||||
session(['currentTeam' => $this->team]);
|
||||
|
||||
Livewire::test(TeamMemberIndex::class)
|
||||
->assertSee('Invite New Member');
|
||||
->assertSee('Invite a member');
|
||||
});
|
||||
|
||||
test('member does not see invite section on team members page', function () {
|
||||
@@ -95,7 +98,7 @@ test('member does not see invite section on team members page', function () {
|
||||
session(['currentTeam' => $this->team]);
|
||||
|
||||
Livewire::test(TeamMemberIndex::class)
|
||||
->assertDontSee('Invite New Member');
|
||||
->assertDontSee('Invite a member');
|
||||
});
|
||||
|
||||
// --- Notification Settings (Discord as representative) ---
|
||||
|
||||
@@ -188,7 +188,7 @@ test('new server chooser lists providers before rendering a creation form', func
|
||||
->assertSee('Hetzner')
|
||||
->assertSee('Vultr')
|
||||
->assertSee('DigitalOcean')
|
||||
->assertSee('Manual')
|
||||
->assertSee('IP address or domain')
|
||||
->assertDontSee('>Select<', false)
|
||||
->assertDontSee('Continue')
|
||||
->assertSee(route('server.create.type', ['type' => 'hetzner']))
|
||||
@@ -203,19 +203,13 @@ test('new server chooser uses compact mobile provider cards', function () {
|
||||
session(['currentTeam' => $this->team]);
|
||||
|
||||
Livewire::test(ServerCreate::class)
|
||||
->assertSee('mx-auto flex w-full max-w-7xl flex-col', false)
|
||||
->assertSee('sm:grid', false)
|
||||
->assertSee('sm:grid-cols-2', false)
|
||||
->assertSee('xl:grid-cols-4', false)
|
||||
->assertSee('gap-3 sm:gap-6', false)
|
||||
->assertSee('dark:hover:border-warning', false)
|
||||
->assertSee('focus-visible:border-coollabs', false)
|
||||
->assertSee('aria-label="Choose Hetzner"', false)
|
||||
->assertSee('p-3 sm:p-6', false)
|
||||
->assertSee('application-settings-form flex flex-col gap-6', false)
|
||||
->assertSee('grid grid-cols-1 gap-3 p-3 sm:grid-cols-2 lg:grid-cols-4', false)
|
||||
->assertSee('grid grid-cols-1 gap-3 p-3 sm:grid-cols-2 lg:grid-cols-3', false)
|
||||
->assertSee('group flex min-h-32 flex-col rounded-xl', false)
|
||||
->assertSee('alt="Hetzner" class="size-8"', false)
|
||||
->assertDontSee('sm:min-h-80', false)
|
||||
->assertSee('size-9 sm:size-14', false)
|
||||
->assertDontSee('size-9 sm:size-14 w-14 h-14', false)
|
||||
->assertSee('hidden sm:block', false)
|
||||
->assertDontSee('>Select<', false)
|
||||
->assertDontSee('<button', false);
|
||||
});
|
||||
@@ -230,12 +224,12 @@ test('new server provider pages render the selected creation flow', function (st
|
||||
->assertDontSee('<h1>New Server</h1>', false)
|
||||
->assertDontSee('Choose how to add your server');
|
||||
|
||||
$response->assertSeeInOrder([$heading, 'Back']);
|
||||
$response->assertSeeInOrder(['New server', 'Change method', $heading]);
|
||||
})->with([
|
||||
['hetzner', 'Hetzner'],
|
||||
['vultr', 'Vultr'],
|
||||
['digital-ocean', 'DigitalOcean'],
|
||||
['manual', 'Manual'],
|
||||
['hetzner', 'Hetzner account'],
|
||||
['vultr', 'Vultr account'],
|
||||
['digital-ocean', 'DigitalOcean account'],
|
||||
['manual', 'Connect a server'],
|
||||
]);
|
||||
|
||||
test('new server provider pages do not show the new token action in the header', function (string $type, string $heading) {
|
||||
@@ -247,27 +241,39 @@ test('new server provider pages do not show the new token action in the header',
|
||||
->assertDontSee('+ New Token')
|
||||
->assertDontSee('+ Add New Token');
|
||||
|
||||
$response->assertSeeInOrder([$heading, 'Back']);
|
||||
$header = str($response->getContent())
|
||||
->after('New server</h1>')
|
||||
->before('application-settings-form')
|
||||
->toString();
|
||||
|
||||
expect($header)
|
||||
->toContain('Change method')
|
||||
->not->toContain('Token');
|
||||
|
||||
// Without tokens, the add-token action lives in the provider account section instead.
|
||||
$response->assertSeeInOrder(['Change method', "{$heading} account", "No {$heading} tokens", "Add {$heading} Token"]);
|
||||
})->with([
|
||||
['hetzner', 'Hetzner'],
|
||||
['vultr', 'Vultr'],
|
||||
['digital-ocean', 'DigitalOcean'],
|
||||
]);
|
||||
|
||||
test('new server provider pages show the new token action in the header when tokens exist', function (string $type, string $provider, string $heading, string $modalTitle) {
|
||||
test('new server provider pages list existing tokens instead of a token action', function (string $type, string $provider, string $heading, string $modalTitle) {
|
||||
$this->actingAs($this->admin);
|
||||
session(['currentTeam' => $this->team]);
|
||||
|
||||
CloudProviderToken::factory()->create([
|
||||
$token = CloudProviderToken::factory()->create([
|
||||
'team_id' => $this->team->id,
|
||||
'provider' => $provider,
|
||||
]);
|
||||
|
||||
$response = $this->get(route('server.create.type', ['type' => $type]))
|
||||
->assertSuccessful()
|
||||
->assertSee($modalTitle);
|
||||
->assertSee(route('server.create.token', ['type' => $type, 'token_uuid' => $token->uuid]))
|
||||
->assertDontSee($modalTitle)
|
||||
->assertDontSee('+ New Token');
|
||||
|
||||
$response->assertSeeInOrder([$heading, 'Back', '+ New Token']);
|
||||
$response->assertSeeInOrder(['Change method', "{$heading} account", $token->name]);
|
||||
})->with([
|
||||
['hetzner', 'hetzner', 'Hetzner', 'Add Hetzner Token'],
|
||||
['vultr', 'vultr', 'Vultr', 'Add Vultr Token'],
|
||||
@@ -280,8 +286,9 @@ test('new server manual page does not show the new token action', function () {
|
||||
|
||||
$this->get(route('server.create.type', ['type' => 'manual']))
|
||||
->assertSuccessful()
|
||||
->assertSee('Manual')
|
||||
->assertDontSee('+ New Token');
|
||||
->assertSee('Connect a server')
|
||||
->assertDontSee('+ New Token')
|
||||
->assertDontSee('Add token');
|
||||
});
|
||||
|
||||
test('member cannot access new server page', function () {
|
||||
|
||||
@@ -16,7 +16,9 @@ use Livewire\Livewire;
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
beforeEach(function () {
|
||||
InstanceSettings::updateOrCreate(['id' => 0]);
|
||||
InstanceSettings::unguarded(function () {
|
||||
InstanceSettings::updateOrCreate(['id' => 0], []);
|
||||
});
|
||||
|
||||
$this->team = Team::factory()->create();
|
||||
|
||||
@@ -197,17 +199,26 @@ test('member does not see terminal link for service', function () {
|
||||
'query' => [],
|
||||
])
|
||||
->assertDontSee('Terminal');
|
||||
|
||||
// The terminal link lives in the service settings sidebar since the resource tab bar was removed.
|
||||
$this->blade(
|
||||
'<x-service.configuration-sidebar :service="$service" current-route="project.service.configuration" />',
|
||||
['service' => $this->service]
|
||||
)
|
||||
->assertSee(route('project.service.configuration', $this->serviceParams), false)
|
||||
->assertDontSee(route('project.service.command', $this->serviceParams), false);
|
||||
});
|
||||
|
||||
test('admin sees terminal link for service', function () {
|
||||
$this->actingAs($this->admin);
|
||||
session(['currentTeam' => $this->team]);
|
||||
|
||||
Livewire::test(ServiceHeading::class, [
|
||||
'service' => $this->service,
|
||||
'parameters' => $this->serviceParams,
|
||||
'query' => [],
|
||||
])
|
||||
// The terminal link lives in the service settings sidebar since the resource tab bar was removed.
|
||||
$this->blade(
|
||||
'<x-service.configuration-sidebar :service="$service" current-route="project.service.configuration" />',
|
||||
['service' => $this->service]
|
||||
)
|
||||
->assertSee(route('project.service.command', $this->serviceParams), false)
|
||||
->assertSee('Terminal');
|
||||
});
|
||||
|
||||
|
||||
@@ -185,7 +185,7 @@ test('member cannot view application webhook secrets', function () {
|
||||
->assertSet('gitlabManualWebhookSecret', null)
|
||||
->assertSet('bitbucketManualWebhookSecret', null)
|
||||
->assertSet('giteaManualWebhookSecret', null)
|
||||
->assertSee('Hidden (only admins can view)')
|
||||
->assertSee('Hidden (only administrators can view)')
|
||||
->assertDontSee('github-secret-value')
|
||||
->assertDontSee('gitlab-secret-value')
|
||||
->assertDontSee('bitbucket-secret-value')
|
||||
|
||||
@@ -76,20 +76,6 @@ beforeEach(function () {
|
||||
session(['currentTeam' => $this->team]);
|
||||
});
|
||||
|
||||
it('renders a highlighted enable backup button and a regular disable backup button', function () {
|
||||
$view = file_get_contents(resource_path('views/livewire/project/database/backup-edit/general.blade.php'));
|
||||
$s3View = file_get_contents(resource_path('views/livewire/project/database/backup-edit/s3.blade.php'));
|
||||
|
||||
expect($view)
|
||||
->toContain('wire:target="toggleEnabled" isHighlighted>Enable Backup</x-forms.button>')
|
||||
->toContain('wire:target="toggleEnabled">Disable Backup</x-forms.button>')
|
||||
->not->toContain('label="Backup Enabled"')
|
||||
->and($s3View)
|
||||
->toContain('wire:target="toggleS3" isHighlighted')
|
||||
->toContain('wire:target="toggleS3">Disable S3</x-forms.button>')
|
||||
->not->toContain('label="S3 Enabled"');
|
||||
});
|
||||
|
||||
it('enables and disables S3 backups from the S3 title action', function () {
|
||||
$s3 = createS3StorageForBackupEditValidationTest($this->team);
|
||||
$backup = createBackupForEditValidationTest($this->team, [
|
||||
@@ -129,11 +115,11 @@ it('shows and saves S3 retention while S3 backups are disabled', function () {
|
||||
]);
|
||||
|
||||
Livewire::test(BackupEdit::class, [
|
||||
'backup' => $backup,
|
||||
'backup' => $backup->fresh(),
|
||||
'availableS3Storages' => $this->team->s3s,
|
||||
'section' => 'retention',
|
||||
])
|
||||
->assertSee('S3 Storage Retention')
|
||||
->assertSee('S3 backups')
|
||||
->set('databaseBackupRetentionAmountS3', 12)
|
||||
->set('databaseBackupRetentionDaysS3', 30)
|
||||
->set('databaseBackupRetentionMaxStorageS3', 4.5)
|
||||
@@ -161,55 +147,49 @@ it('splits standalone database backup settings and executions across dedicated u
|
||||
$this->get($generalUrl)
|
||||
->assertOk()
|
||||
->assertSee('General')
|
||||
->assertSee('S3')
|
||||
->assertSee('S3 storage')
|
||||
->assertSee('Retention')
|
||||
->assertSee('Executions')
|
||||
->assertSee('Danger Zone')
|
||||
->assertSee('Frequency')
|
||||
->assertDontSee('S3 Enabled')
|
||||
->assertDontSee('Number of backups to keep')
|
||||
->assertDontSee('Cleanup Failed Backups')
|
||||
->assertDontSee('Delete Backups and Schedule');
|
||||
->assertDontSee('Enable S3')
|
||||
->assertDontSee('Disable S3')
|
||||
->assertDontSee('Backups to keep')
|
||||
->assertDontSee('Clean failed backups')
|
||||
->assertDontSee('Delete schedule');
|
||||
|
||||
$this->get($generalUrl.'/s3')
|
||||
->assertOk()
|
||||
->assertSeeText('No validated S3 storage')
|
||||
->assertDontSee('Disable Local Backup')
|
||||
->assertDontSee('Local copy')
|
||||
->assertDontSee('Enable S3')
|
||||
->assertDontSee('Disable S3')
|
||||
->assertDontSee('S3 Storage Retention')
|
||||
->assertDontSee('Local Backup Retention')
|
||||
->assertDontSee('Backups to keep')
|
||||
->assertDontSee('Frequency')
|
||||
->assertDontSee('Cleanup Failed Backups');
|
||||
|
||||
$s3View = file_get_contents(resource_path('views/livewire/project/database/backup-edit/s3.blade.php'));
|
||||
expect(strpos($s3View, '<span>S3 Storage</span>'))
|
||||
->toBeLessThan(strpos($s3View, 'label="Disable Local Backup"'));
|
||||
->assertDontSee('Clean failed backups');
|
||||
|
||||
$this->get($generalUrl.'/retention')
|
||||
->assertOk()
|
||||
->assertSee('Local Backup Retention')
|
||||
->assertSee('S3 Storage Retention')
|
||||
->assertSee('Number of backups to keep')
|
||||
->assertSee('Local backups')
|
||||
->assertSee('S3 backups')
|
||||
->assertSee('Backups to keep')
|
||||
->assertDontSee('Frequency')
|
||||
->assertDontSee('Cleanup Failed Backups');
|
||||
->assertDontSee('Clean failed backups');
|
||||
|
||||
$this->get($generalUrl.'/executions')
|
||||
->assertOk()
|
||||
->assertSee('<h2 class="py-0">Executions</h2>', false)
|
||||
->assertDontSee('Executions <span', false)
|
||||
->assertSee('Cleanup Failed Backups')
|
||||
->assertSee('Clean failed backups')
|
||||
->assertDontSee('Frequency')
|
||||
->assertDontSee('Number of backups to keep');
|
||||
->assertDontSee('Backups to keep');
|
||||
|
||||
$this->get($generalUrl.'/danger')
|
||||
->assertOk()
|
||||
->assertSee('Danger Zone')
|
||||
->assertSee('Delete Scheduled Backup')
|
||||
->assertSee('Delete Backups and Schedule')
|
||||
->assertSee('Delete backup schedule')
|
||||
->assertSee('Delete schedule')
|
||||
->assertDontSee('Frequency')
|
||||
->assertDontSee('Number of backups to keep')
|
||||
->assertDontSee('Cleanup Failed Backups');
|
||||
->assertDontSee('Backups to keep')
|
||||
->assertDontSee('Clean failed backups');
|
||||
});
|
||||
|
||||
it('enables and disables a scheduled database backup from the title action', function () {
|
||||
@@ -220,10 +200,12 @@ it('enables and disables a scheduled database backup from the title action', fun
|
||||
|
||||
$component = Livewire::test(BackupEdit::class, ['backup' => $backup->fresh(), 'availableS3Storages' => $this->team->s3s])
|
||||
->assertSet('backupEnabled', false)
|
||||
->assertSee('Enable Backup')
|
||||
->assertSee('Enable backup')
|
||||
->assertDontSee('Disable backup')
|
||||
->call('toggleEnabled')
|
||||
->assertSet('backupEnabled', true)
|
||||
->assertSee('Disable Backup');
|
||||
->assertSee('Disable backup')
|
||||
->assertDontSee('Enable backup');
|
||||
|
||||
expect($backup->refresh()->enabled)->toBeTruthy();
|
||||
|
||||
@@ -258,7 +240,7 @@ it('redirects to executions after queuing a database backup with unusable S3 sto
|
||||
];
|
||||
|
||||
Livewire::test(BackupEdit::class, [
|
||||
'backup' => $backup,
|
||||
'backup' => $backup->fresh(),
|
||||
'availableS3Storages' => $this->team->s3s,
|
||||
])
|
||||
->call('backupNow')
|
||||
@@ -490,7 +472,6 @@ it('shows only an empty S3 state when no storages are available', function () {
|
||||
]);
|
||||
|
||||
Livewire::test(BackupEdit::class, ['backup' => $backup->fresh(), 'availableS3Storages' => $this->team->s3s, 'section' => 's3'])
|
||||
->assertSeeHtml('<h2>S3 storage</h2>')
|
||||
->assertSeeText('No validated S3 storage')
|
||||
->assertSeeHtml('href="'.route('storage.index').'"')
|
||||
->assertSeeText('Open S3 storage')
|
||||
@@ -498,7 +479,7 @@ it('shows only an empty S3 state when no storages are available', function () {
|
||||
->assertDontSee('Enable S3')
|
||||
->assertDontSee('Disable S3')
|
||||
->assertDontSee('S3 Storage')
|
||||
->assertDontSee('Disable Local Backup')
|
||||
->assertDontSee('Local copy')
|
||||
->assertDontSee('No S3 storage available');
|
||||
});
|
||||
|
||||
@@ -534,8 +515,10 @@ it('shows when S3 backups are currently disabled', function () {
|
||||
]);
|
||||
|
||||
Livewire::test(BackupEdit::class, ['backup' => $backup->fresh(), 'availableS3Storages' => $this->team->s3s, 'section' => 's3'])
|
||||
->assertSee('S3 Storage')
|
||||
->assertSee('(currently disabled)');
|
||||
// Disabled S3 backups are indicated by the "Enable S3" title action
|
||||
->assertSee('S3 storage')
|
||||
->assertSee('Enable S3')
|
||||
->assertDontSee('Disable S3');
|
||||
});
|
||||
|
||||
it('saves selected S3 storage immediately when it changes', function () {
|
||||
@@ -616,22 +599,3 @@ it('disables Back up now after refresh when the database stops', function () {
|
||||
|
||||
expect($component->html())->toMatch('/<button\s+disabled[^>]*wire:click="backupNow"/s');
|
||||
});
|
||||
|
||||
it('renders S3 backup selectors outside the scrollable modal', function () {
|
||||
createS3StorageForBackupEditValidationTest($this->team);
|
||||
$backup = createBackupForEditValidationTest($this->team);
|
||||
$html = Livewire::test(BackupEdit::class, [
|
||||
'backup' => $backup->fresh(),
|
||||
'availableS3Storages' => $this->team->s3s,
|
||||
'section' => 's3',
|
||||
])->html();
|
||||
|
||||
$dom = new DOMDocument;
|
||||
@$dom->loadHTML($html);
|
||||
$xpath = new DOMXPath($dom);
|
||||
foreach (['s3StorageId-panel', 'disableLocalBackup-panel'] as $panelId) {
|
||||
$panels = $xpath->query('//template[@x-teleport="body"]/div[@id="'.$panelId.'"]');
|
||||
expect($panels->length)->toBe(1);
|
||||
expect($panels->item(0)->getAttribute('style'))->toContain('position: fixed', 'z-index: 9999');
|
||||
}
|
||||
});
|
||||
|
||||
@@ -2,7 +2,6 @@
|
||||
|
||||
use App\Jobs\DatabaseBackupJob;
|
||||
use App\Livewire\Project\Database\BackupEdit;
|
||||
use App\Livewire\Project\Database\BackupNow;
|
||||
use App\Livewire\Project\Service\VolumeBackup\Index;
|
||||
use App\Models\Environment;
|
||||
use App\Models\InstanceSettings;
|
||||
@@ -63,8 +62,6 @@ beforeEach(function () {
|
||||
dataset('database backup controls', [
|
||||
'standalone settings' => [BackupEdit::class, 'standalone'],
|
||||
'service settings' => [BackupEdit::class, 'service'],
|
||||
'standalone button' => [BackupNow::class, 'standalone'],
|
||||
'service button' => [BackupNow::class, 'service'],
|
||||
'service list' => [Index::class, 'service'],
|
||||
]);
|
||||
|
||||
|
||||
@@ -117,7 +117,7 @@ it('renders frontend-only database backup search data for database names and fre
|
||||
->assertSee('Archive-Bucket')
|
||||
->assertSee('archive-bucket', false)
|
||||
->assertSee('backup.s3_storage.includes(query)', false)
|
||||
->assertSee('Search backup schedules')
|
||||
->assertSee('placeholder="Search backups"', false)
|
||||
->assertSee('x-model="search"', false)
|
||||
->assertSee('x-show=', false)
|
||||
->assertSee('No matching backup schedules');
|
||||
|
||||
@@ -48,24 +48,22 @@ it('hides the breadcrumb trail on mobile while keeping the current status visibl
|
||||
]));
|
||||
|
||||
$response->assertSuccessful();
|
||||
$response->assertSee('flex min-w-0 flex-col gap-1 md:hidden', false);
|
||||
$response->assertSee('flex min-w-0 items-center text-xs text-neutral-400', false);
|
||||
$response->assertSee('hidden flex-wrap items-center gap-y-1 md:flex', false);
|
||||
$response->assertSee('flex flex-wrap items-center gap-1', false);
|
||||
$response->assertSee(
|
||||
'scrollbar flex min-h-10 w-full flex-nowrap items-center gap-6 overflow-x-scroll overflow-y-hidden pb-1 whitespace-nowrap md:w-auto md:overflow-visible',
|
||||
false,
|
||||
);
|
||||
$response->assertSee('shrink-0', false);
|
||||
$response->assertSee('Actions');
|
||||
$response->assertSee('dropdown-item-touch', false);
|
||||
$response->assertSee('hidden flex-wrap items-center gap-2 md:flex', false);
|
||||
$response->assertSee('window.innerWidth >= 768', false);
|
||||
$response->assertSee(':style="panelStyles"', false);
|
||||
$response->assertSee('absolute top-full z-50 mt-1 min-w-max max-w-[calc(100vw-1rem)] md:top-0 md:mt-6', false);
|
||||
$response->assertSee('Pure Dockerfile Example');
|
||||
|
||||
// The resource breadcrumb lives in the top bar (project / environment / resource switchers).
|
||||
$response->assertSee('title="Switch project"', false);
|
||||
$response->assertSee('title="Switch environment"', false);
|
||||
$response->assertSee('title="Switch resource"', false);
|
||||
|
||||
// Desktop resource actions dock into the top bar; the slot is hidden below xl.
|
||||
$response->assertSee('<div id="resource-action-hud-slot" class="hidden shrink-0 items-center xl:flex"></div>', false);
|
||||
|
||||
// Below xl, the heading keeps the resource name, current status, and a split action visible.
|
||||
expect(preg_match(
|
||||
'/<div class="mb-3 w-full xl:hidden">.*?<h1[^>]*>\s*Pure Dockerfile Example\s*<\/h1>.*?Application status/s',
|
||||
$response->getContent()
|
||||
))->toBe(1);
|
||||
$response->assertSee('id="application-mobile-actions"', false);
|
||||
$response->assertSee('Running');
|
||||
$response->assertSee('pt-2 pb-4 md:pb-10', false);
|
||||
|
||||
expect($response->getContent())->not->toContain('hidden pt-2 pb-10 md:flex');
|
||||
});
|
||||
|
||||
@@ -27,6 +27,9 @@ it('sets unreachable fields on servers when subscription ends', function () {
|
||||
});
|
||||
|
||||
it('cleans up unsubscribed server IP after 7 days via cleanup command', function () {
|
||||
// Subscriptions only exist on Coolify Cloud; self-hosted instances force-disable the server instead.
|
||||
config(['constants.coolify.self_hosted' => false]);
|
||||
|
||||
$team = Team::factory()->create();
|
||||
$server = Server::factory()->create([
|
||||
'team_id' => $team->id,
|
||||
|
||||
@@ -60,7 +60,7 @@ it('shows link cloud provider dropdown with available unlinked providers', funct
|
||||
]);
|
||||
|
||||
Livewire::test(Show::class, ['server_uuid' => $this->server->uuid])
|
||||
->assertSee('Link Cloud Provider')
|
||||
->assertSee('Link provider')
|
||||
->assertSee('Hetzner')
|
||||
->assertSee('DigitalOcean')
|
||||
->assertSee('Vultr')
|
||||
@@ -70,13 +70,13 @@ it('shows link cloud provider dropdown with available unlinked providers', funct
|
||||
->assertSee('Server ID')
|
||||
->assertSee('Droplet ID')
|
||||
->assertSee('Instance ID')
|
||||
->assertSee('Search by IP')
|
||||
->assertSee('Search by server IP')
|
||||
->assertSee('Search');
|
||||
});
|
||||
|
||||
it('hides link cloud provider dropdown when no providers can be linked', function () {
|
||||
Livewire::test(Show::class, ['server_uuid' => $this->server->uuid])
|
||||
->assertDontSee('Link Cloud Provider');
|
||||
->assertDontSee('Link provider');
|
||||
});
|
||||
|
||||
it('does not list providers already linked to the server', function () {
|
||||
@@ -97,7 +97,7 @@ it('does not list providers already linked to the server', function () {
|
||||
$this->server->update(['hetzner_server_id' => 123]);
|
||||
|
||||
Livewire::test(Show::class, ['server_uuid' => $this->server->uuid])
|
||||
->assertSee('Link Cloud Provider')
|
||||
->assertSee('Link provider')
|
||||
->assertSee('Vultr Token')
|
||||
->assertDontSee('Hetzner Token');
|
||||
});
|
||||
|
||||
@@ -2,74 +2,6 @@
|
||||
|
||||
use Illuminate\Support\Facades\Blade;
|
||||
|
||||
it('uses the large modal treatment for the compose editor', function () {
|
||||
$view = file_get_contents(resource_path('views/livewire/project/service/stack-form.blade.php'));
|
||||
$modal = file_get_contents(resource_path('views/components/modal-input.blade.php'));
|
||||
|
||||
expect($view)
|
||||
->toContain('title="Docker Compose"')
|
||||
->toContain(':isLarge="true"')
|
||||
->toContain('<x-slot:footer>')
|
||||
->not->toContain('<x-slot:headerActions>')
|
||||
->toContain("\$dispatch('compose-preview-toggle')")
|
||||
->toContain("\$dispatch('compose-save')")
|
||||
->toContain('@compose-save-finished.window="saving = false"')
|
||||
->toContain('@compose-validate-finished.window="validating = false"')
|
||||
->toContain('@click="validating = true; $dispatch(\'compose-validate\')"')
|
||||
->toContain('x-bind:disabled="validating"')
|
||||
->toContain('<x-loading-on-button x-show="validating" x-cloak />')
|
||||
->toContain('<x-loading-on-button x-show="saving" x-cloak />')
|
||||
->toContain('x-bind:disabled="saving"')
|
||||
->not->toContain('name="refresh"')
|
||||
->not->toContain("saving ? 'Saving...' : 'Save changes'")
|
||||
->not->toContain(' :disabled="saving"')
|
||||
->toContain('Preview generated Compose')
|
||||
->toContain('Back to source Compose')
|
||||
->toContain('Save changes')
|
||||
->toContain('isHighlighted');
|
||||
|
||||
expect($modal)->toContain('lg:w-[95vw]! lg:max-w-7xl!');
|
||||
});
|
||||
|
||||
it('renders the compose editor with clear guidance settings and actions', function () {
|
||||
$view = file_get_contents(resource_path('views/livewire/project/service/edit-compose.blade.php'));
|
||||
|
||||
expect($view)
|
||||
->toContain('<x-callout type="info" title="Volume names">')
|
||||
->not->toContain('View the final names')
|
||||
->toContain('Use plain-text editor')
|
||||
->toContain('min-h-[24rem]')
|
||||
->toContain('@compose-preview-toggle.window')
|
||||
->toContain('@compose-save.window')
|
||||
->toContain('@compose-validate.window="$wire.validateCompose().finally(() => $dispatch(\'compose-validate-finished\'))"')
|
||||
->not->toContain("finally(() => \$dispatch('compose-save-finished'))")
|
||||
->not->toContain('sticky bottom-0')
|
||||
->not->toContain('Cancel')
|
||||
->not->toContain('Show Normal Textarea')
|
||||
->not->toContain('Show Deployable Compose');
|
||||
});
|
||||
|
||||
it('keeps the compose modal usable on mobile screens', function () {
|
||||
$stackForm = file_get_contents(resource_path('views/livewire/project/service/stack-form.blade.php'));
|
||||
$editor = file_get_contents(resource_path('views/livewire/project/service/edit-compose.blade.php'));
|
||||
$modal = file_get_contents(resource_path('views/components/modal-input.blade.php'));
|
||||
|
||||
expect($modal)
|
||||
->toContain('justify-center p-2')
|
||||
->toContain('sm:p-4')
|
||||
->toContain('flex-wrap! sm:flex-nowrap!')
|
||||
->toContain('@isset($footer)')
|
||||
->toContain('justify-end gap-2 border-t')
|
||||
->toContain('order-2 sm:order-none');
|
||||
|
||||
expect($stackForm)
|
||||
->toContain('flex-wrap items-center justify-end gap-2');
|
||||
|
||||
expect($editor)
|
||||
->toContain('flex-col items-stretch')
|
||||
->toContain('sm:flex-row sm:flex-wrap sm:items-center');
|
||||
});
|
||||
|
||||
it('keeps the save button loading until the parent compose save finishes', function () {
|
||||
$component = file_get_contents(app_path('Livewire/Project/Service/StackForm.php'));
|
||||
|
||||
|
||||
@@ -21,22 +21,3 @@ it('evaluates a resolve expression at click time instead of a static value', fun
|
||||
$html->assertSee('await ($wire.copyValue())', false)
|
||||
->assertDontSee('disabled', false);
|
||||
});
|
||||
|
||||
it('is the single clipboard implementation shared by its call sites', function () {
|
||||
expect(file_get_contents(resource_path('js/copy-button.js')))
|
||||
->toContain("window.Alpine.data('copyButton'");
|
||||
|
||||
expect(file_get_contents(resource_path('js/app.js')))
|
||||
->toContain('initializeCopyButtonComponent');
|
||||
|
||||
$modalConfirmation = file_get_contents(resource_path('views/components/modal-confirmation.blade.php'));
|
||||
$backupExecutions = file_get_contents(resource_path('views/livewire/project/database/backup-executions.blade.php'));
|
||||
|
||||
expect($modalConfirmation)
|
||||
->toContain('<x-copy-button resolve="decodedText"')
|
||||
->not->toContain('navigator.clipboard');
|
||||
|
||||
expect($backupExecutions)
|
||||
->toContain('<x-copy-button :value="data_get($execution, \'filename\', \'\')" label="Copy backup path"')
|
||||
->not->toContain('navigator.clipboard');
|
||||
});
|
||||
|
||||
@@ -2,28 +2,34 @@
|
||||
|
||||
use App\Models\Application;
|
||||
use App\Models\Environment;
|
||||
use App\Models\InstanceSettings;
|
||||
use App\Models\Project;
|
||||
use App\Models\Server;
|
||||
use App\Models\Service;
|
||||
use App\Models\StandaloneDocker;
|
||||
use App\Models\Team;
|
||||
use App\Models\User;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
beforeEach(function () {
|
||||
InstanceSettings::forceCreate(['id' => 0]);
|
||||
|
||||
// Attacker: Team A
|
||||
$this->userA = User::factory()->create();
|
||||
$this->teamA = Team::factory()->create();
|
||||
$this->userA->teams()->attach($this->teamA, ['role' => 'owner']);
|
||||
|
||||
$this->serverA = Server::factory()->create(['team_id' => $this->teamA->id]);
|
||||
$this->destinationA = StandaloneDocker::factory()->create(['server_id' => $this->serverA->id]);
|
||||
$this->destinationA = StandaloneDocker::where('server_id', $this->serverA->id)->firstOrFail();
|
||||
$this->projectA = Project::factory()->create(['team_id' => $this->teamA->id]);
|
||||
$this->environmentA = Environment::factory()->create(['project_id' => $this->projectA->id]);
|
||||
|
||||
// Victim: Team B
|
||||
$this->teamB = Team::factory()->create();
|
||||
$this->serverB = Server::factory()->create(['team_id' => $this->teamB->id]);
|
||||
$this->destinationB = StandaloneDocker::factory()->create(['server_id' => $this->serverB->id]);
|
||||
$this->destinationB = StandaloneDocker::where('server_id', $this->serverB->id)->firstOrFail();
|
||||
$this->projectB = Project::factory()->create(['team_id' => $this->teamB->id]);
|
||||
$this->environmentB = Environment::factory()->create(['project_id' => $this->projectB->id]);
|
||||
|
||||
@@ -37,6 +43,7 @@ beforeEach(function () {
|
||||
'environment_id' => $this->environmentB->id,
|
||||
'destination_id' => $this->destinationB->id,
|
||||
'destination_type' => StandaloneDocker::class,
|
||||
'server_id' => $this->serverB->id,
|
||||
]);
|
||||
|
||||
// Act as attacker
|
||||
@@ -85,6 +92,7 @@ test('can access logs of own service', function () {
|
||||
'environment_id' => $this->environmentA->id,
|
||||
'destination_id' => $this->destinationA->id,
|
||||
'destination_type' => StandaloneDocker::class,
|
||||
'server_id' => $this->serverA->id,
|
||||
]);
|
||||
|
||||
$response = $this->get(route('project.service.logs', [
|
||||
|
||||
@@ -8,6 +8,7 @@ use App\Livewire\Project\DeleteProject;
|
||||
use App\Models\Application;
|
||||
use App\Models\ApplicationDeploymentQueue;
|
||||
use App\Models\Environment;
|
||||
use App\Models\InstanceSettings;
|
||||
use App\Models\PrivateKey;
|
||||
use App\Models\Project;
|
||||
use App\Models\Server;
|
||||
@@ -21,6 +22,8 @@ use Livewire\Livewire;
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
beforeEach(function () {
|
||||
InstanceSettings::forceCreate(['id' => 0, 'is_api_enabled' => true]);
|
||||
|
||||
// Attacker: Team A
|
||||
$this->userA = User::factory()->create();
|
||||
$this->teamA = Team::factory()->create();
|
||||
@@ -170,7 +173,8 @@ describe('DeployController API Server IDOR', function () {
|
||||
// Create a deployment queue entry that references Team B's server as build_server
|
||||
$application = Application::factory()->create([
|
||||
'environment_id' => $this->environmentA->id,
|
||||
'destination_id' => StandaloneDocker::factory()->create(['server_id' => $this->serverA->id])->id,
|
||||
// Server::booted() already creates the server's default StandaloneDocker destination.
|
||||
'destination_id' => StandaloneDocker::where('server_id', $this->serverA->id)->firstOrFail()->id,
|
||||
'destination_type' => StandaloneDocker::class,
|
||||
]);
|
||||
|
||||
@@ -183,10 +187,12 @@ describe('DeployController API Server IDOR', function () {
|
||||
]);
|
||||
|
||||
$token = $this->userA->createToken('test-token', ['*']);
|
||||
// Authenticate the API call with the bearer token only, not the session user from beforeEach.
|
||||
$this->app['auth']->forgetGuards();
|
||||
|
||||
$response = $this->withHeaders([
|
||||
'Authorization' => 'Bearer '.$token->plainTextToken,
|
||||
])->deleteJson("/api/v1/deployments/{$deployment->deployment_uuid}");
|
||||
])->postJson("/api/v1/deployments/{$deployment->deployment_uuid}/cancel");
|
||||
|
||||
// The cancellation should proceed but the build_server should NOT be found
|
||||
// (team-scoped query returns null for Team B's server)
|
||||
|
||||
@@ -53,17 +53,6 @@ function createDashboardDeployment(array $overrides = []): ApplicationDeployment
|
||||
], $overrides));
|
||||
}
|
||||
|
||||
it('shows a visible yellow inset focus state on deployment rows', function () {
|
||||
$view = file_get_contents(resource_path('views/livewire/dashboard/active-deployments.blade.php'));
|
||||
|
||||
expect(substr_count($view, 'focus-visible:bg-warning/10'))->toBe(4)
|
||||
->and(substr_count($view, 'focus-visible:ring-1'))->toBe(2)
|
||||
->and(substr_count($view, 'focus-visible:ring-inset'))->toBe(2)
|
||||
->and(substr_count($view, 'focus-visible:ring-warning'))->toBe(2)
|
||||
->and(substr_count($view, 'focus-visible:ring-offset-0'))->toBe(2)
|
||||
->and(substr_count($view, 'dark:focus-visible:bg-warning/10'))->toBe(2);
|
||||
});
|
||||
|
||||
it('hides the deployments section when there is nothing to show', function () {
|
||||
Livewire::test(ActiveDeployments::class)
|
||||
->assertDontSee('Active and recent deployment activity')
|
||||
|
||||
@@ -58,22 +58,6 @@ test('instance administrators can configure the dashboard HTTPS redirect', funct
|
||||
expect($settings->fresh()->is_dashboard_force_https_enabled)->toBeFalse();
|
||||
});
|
||||
|
||||
test('dashboard HTTPS redirect saves immediately when changed', function () {
|
||||
$contents = file_get_contents(resource_path('views/livewire/settings/index.blade.php'));
|
||||
|
||||
expect($contents)
|
||||
->toMatch('/id="is_dashboard_force_https_enabled"[\s\S]*?onChange="submit"/')
|
||||
->and($contents)->not->toContain('targets="fqdn,is_dashboard_force_https_enabled');
|
||||
});
|
||||
|
||||
test('dashboard HTTPS redirect is next to the URL on desktop', function () {
|
||||
$contents = file_get_contents(resource_path('views/livewire/settings/index.blade.php'));
|
||||
|
||||
expect($contents)
|
||||
->toContain("'lg:col-span-2' => !str_starts_with")
|
||||
->not->toContain('<div class="lg:col-span-2 max-w-md">');
|
||||
});
|
||||
|
||||
test('dashboard HTTPS redirect control is hidden for an HTTP URL', function () {
|
||||
$rootTeam = Team::find(0) ?? Team::factory()->create(['id' => 0]);
|
||||
Server::factory()->create(['id' => 0, 'team_id' => $rootTeam->id]);
|
||||
|
||||
@@ -63,44 +63,3 @@ it('renders dashboard metrics charts on the server index grid', function () {
|
||||
->assertSeeHtml("dashboard-server-metrics-{$enabledServer->uuid}")
|
||||
->assertDontSeeHtml("dashboard-server-metrics-{$disabledServer->uuid}");
|
||||
});
|
||||
|
||||
it('configures the dashboard chart as a ten minute cpu and memory sparkline with hover details', function () {
|
||||
$chart = file_get_contents(resource_path('views/livewire/dashboard/server-metrics-chart.blade.php'));
|
||||
$component = file_get_contents(app_path('Livewire/Dashboard/ServerMetricsChart.php'));
|
||||
|
||||
expect($component)
|
||||
->toContain('$this->server->getCpuMetrics(10)')
|
||||
->toContain('$this->server->getMemoryMetrics(10)');
|
||||
expect($chart)
|
||||
->toContain('new ApexCharts')
|
||||
->toContain('sparkline: { enabled: true }')
|
||||
->toContain('absolute right-0 bottom-0 h-2/3 w-full')
|
||||
->toContain('[&_.apexcharts-svg]:overflow-hidden')
|
||||
->not->toContain('w-full overflow-hidden rounded-b-xl')
|
||||
->toContain('CPU:')
|
||||
->toContain('Memory:')
|
||||
->toContain('formatLocalTimestamp(timestamp)')
|
||||
->toContain('formatUtcTimestamp(timestamp)')
|
||||
->toContain("min: 0,\n max: 100,")
|
||||
->toContain('labels: { show: false }');
|
||||
});
|
||||
|
||||
it('refreshes dashboard metrics every minute while visible and after returning from the background', function () {
|
||||
$chart = file_get_contents(resource_path('views/livewire/dashboard/server-metrics-chart.blade.php'));
|
||||
|
||||
expect($chart)
|
||||
->toContain('window.setInterval')
|
||||
->toContain('60000')
|
||||
->toContain('document.hidden')
|
||||
->toContain("document.addEventListener('visibilitychange'")
|
||||
->toContain('Date.now() - this.hiddenAt >= 60000')
|
||||
->toContain('$wire.loadData()')
|
||||
->toContain('window.clearInterval')
|
||||
->toContain("document.removeEventListener('visibilitychange'");
|
||||
});
|
||||
|
||||
it('keeps the status badge only on server cards without metrics', function () {
|
||||
$dashboard = file_get_contents(resource_path('views/livewire/dashboard.blade.php'));
|
||||
|
||||
expect($dashboard)->toContain('@unless ($server->isMetricsEnabled())');
|
||||
});
|
||||
|
||||
@@ -30,7 +30,6 @@ it('shows an add private key button when no private keys exist', function () {
|
||||
->assertSee('A private key is required')
|
||||
->assertSee('Add an SSH private key before connecting your first server.')
|
||||
->assertSee('Add private key')
|
||||
->assertSeeHtml('button button-highlighted')
|
||||
->assertSee(route('security.private-key.index'), false);
|
||||
});
|
||||
|
||||
@@ -66,35 +65,6 @@ it('does not show server empty state when servers exist', function () {
|
||||
->assertDontSee('No servers yet');
|
||||
});
|
||||
|
||||
it('renders clickable server cards without a decorative arrow control', function () {
|
||||
$privateKey = PrivateKey::factory()->create([
|
||||
'team_id' => $this->team->id,
|
||||
]);
|
||||
|
||||
$server = Server::factory()->create([
|
||||
'team_id' => $this->team->id,
|
||||
'private_key_id' => $privateKey->id,
|
||||
'name' => 'Production Host',
|
||||
]);
|
||||
|
||||
Livewire::test(Dashboard::class)
|
||||
->assertSee('Production Host')
|
||||
->assertSeeHtml('aria-label="Open Production Host"')
|
||||
->assertSeeHtml(route('server.show', ['server_uuid' => $server->uuid]))
|
||||
// Decorative arrow used z-10 over the full-card link and blocked clicks.
|
||||
->assertDontSeeHtml('group-hover:bg-neutral-100 group-hover:text-black dark:text-fg-faint dark:group-hover:bg-white/[0.06]');
|
||||
});
|
||||
|
||||
it('keeps resource grid cards free of decorative open chevrons', function () {
|
||||
$view = file_get_contents(resource_path('views/livewire/project/resource/index.blade.php'));
|
||||
|
||||
expect($view)
|
||||
->toContain('absolute inset-0 rounded-xl')
|
||||
->toContain('aria-label="`Open ${item.name}`"')
|
||||
// Decorative card chevrons used a dedicated size-7 open control.
|
||||
->not->toContain('flex size-7 shrink-0 items-center justify-center rounded-md text-neutral-400 transition-colors hover:bg-neutral-100 hover:text-black dark:text-fg-faint dark:hover:bg-white/[0.06] dark:hover:text-fg');
|
||||
});
|
||||
|
||||
it('hides the add private key button for members without create permission', function () {
|
||||
$member = User::factory()->create();
|
||||
$member->teams()->attach($this->team, ['role' => 'member']);
|
||||
|
||||
@@ -1,8 +1,19 @@
|
||||
<?php
|
||||
|
||||
use App\Livewire\Project\Database\Health;
|
||||
use App\Models\Environment;
|
||||
use App\Models\InstanceSettings;
|
||||
use App\Models\Project;
|
||||
use App\Models\Server;
|
||||
use App\Models\StandaloneDocker;
|
||||
use App\Models\StandalonePostgresql;
|
||||
use App\Models\Team;
|
||||
use App\Models\User;
|
||||
use Illuminate\Auth\Access\AuthorizationException;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Livewire\Livewire;
|
||||
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
it('defaults to an enabled healthcheck when nothing is configured', function () {
|
||||
$database = new StandalonePostgresql;
|
||||
@@ -113,63 +124,38 @@ it('does not mark configuration changed when health update authorization fails',
|
||||
});
|
||||
|
||||
it('toggles database healthcheck and marks configuration changed', function () {
|
||||
$database = new class
|
||||
{
|
||||
public ?string $config_hash = 'existing';
|
||||
InstanceSettings::forceCreate(['id' => 0]);
|
||||
$user = User::factory()->create();
|
||||
$team = Team::factory()->create();
|
||||
$user->teams()->attach($team, ['role' => 'owner']);
|
||||
$this->actingAs($user);
|
||||
session(['currentTeam' => $team]);
|
||||
|
||||
public bool $health_check_enabled = false;
|
||||
$server = Server::factory()->create(['team_id' => $team->id]);
|
||||
$destination = StandaloneDocker::where('server_id', $server->id)->firstOrFail();
|
||||
$project = Project::factory()->create(['team_id' => $team->id]);
|
||||
$environment = Environment::factory()->create(['project_id' => $project->id]);
|
||||
$database = StandalonePostgresql::create([
|
||||
'name' => 'db',
|
||||
'postgres_user' => 'postgres',
|
||||
'postgres_password' => 'password',
|
||||
'postgres_db' => 'db',
|
||||
'image' => 'postgres:17',
|
||||
'health_check_enabled' => false,
|
||||
'environment_id' => $environment->id,
|
||||
'destination_id' => $destination->id,
|
||||
'destination_type' => $destination->getMorphClass(),
|
||||
]);
|
||||
$database->config_hash = 'existing';
|
||||
$database->saveQuietly();
|
||||
$database->refresh();
|
||||
|
||||
public int $health_check_interval = 15;
|
||||
Livewire::test(Health::class, ['database' => $database])
|
||||
->assertSet('healthCheckEnabled', false)
|
||||
->call('toggleHealthcheck')
|
||||
->assertSet('healthCheckEnabled', true)
|
||||
->assertDispatched('success')
|
||||
->assertDispatched('configurationChanged');
|
||||
|
||||
public int $health_check_timeout = 5;
|
||||
|
||||
public int $health_check_retries = 5;
|
||||
|
||||
public int $health_check_start_period = 5;
|
||||
|
||||
public int $saveCalls = 0;
|
||||
|
||||
public function save(): void
|
||||
{
|
||||
$this->saveCalls++;
|
||||
}
|
||||
};
|
||||
|
||||
$component = new class extends Health
|
||||
{
|
||||
public array $dispatchedEvents = [];
|
||||
|
||||
public function authorize($ability, $arguments = [])
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
public function dispatch($event, ...$params)
|
||||
{
|
||||
$this->dispatchedEvents[] = $event;
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
public function syncData(bool $toModel = false): void
|
||||
{
|
||||
if ($toModel) {
|
||||
$this->database->health_check_enabled = $this->healthCheckEnabled;
|
||||
$this->database->save();
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
$component->database = $database;
|
||||
$component->healthCheckEnabled = false;
|
||||
$component->healthCheckInterval = 15;
|
||||
$component->healthCheckTimeout = 5;
|
||||
$component->healthCheckRetries = 5;
|
||||
$component->healthCheckStartPeriod = 5;
|
||||
|
||||
$component->toggleHealthcheck();
|
||||
|
||||
expect($database->health_check_enabled)->toBeTrue()
|
||||
->and($database->saveCalls)->toBe(1)
|
||||
->and($component->dispatchedEvents)->toBe(['success', 'configurationChanged']);
|
||||
expect($database->fresh()->health_check_enabled)->toBeTrue();
|
||||
});
|
||||
|
||||
@@ -1,18 +1,12 @@
|
||||
<?php
|
||||
|
||||
it('keeps editable standalone database storage actions in the table action column', function () {
|
||||
it('gates standalone database storage actions behind the update permission', function () {
|
||||
$component = file_get_contents(app_path('Livewire/Project/Shared/Storages/All.php'));
|
||||
$view = file_get_contents(resource_path('views/livewire/project/shared/storages/all.blade.php'));
|
||||
|
||||
expect($component)
|
||||
->toContain('$this->showActionsColumn = $this->canUpdate;')
|
||||
->toContain('$this->showBackupAction = $this->resource instanceof Application')
|
||||
->toContain('if (! $this->showBackupAction)')
|
||||
->and($view)
|
||||
->toContain('@if ($showActionsColumn)')
|
||||
->toContain('@if ($showBackupAction)')
|
||||
->toContain('title="New Scheduled Backup"')
|
||||
->toContain('<livewire:project.database.create-scheduled-backup :database="$resource"')
|
||||
->toContain('aria-label="Configure backup"')
|
||||
->toContain('<x-reicon name="database" class="size-4" />');
|
||||
->toContain('canGate="update"');
|
||||
});
|
||||
|
||||
@@ -14,7 +14,7 @@ use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
beforeEach(function () {
|
||||
InstanceSettings::updateOrCreate(['id' => 0]);
|
||||
InstanceSettings::forceCreate(['id' => 0]);
|
||||
|
||||
$this->team = Team::factory()->create();
|
||||
$this->user = User::factory()->create();
|
||||
@@ -138,7 +138,7 @@ describe('POST /api/v1/databases/postgresql', function () {
|
||||
'instant_deploy' => false,
|
||||
]);
|
||||
|
||||
$response->assertStatus(200);
|
||||
$response->assertStatus(201);
|
||||
$uuid = $response->json('uuid');
|
||||
$database = StandalonePostgresql::whereUuid($uuid)->first();
|
||||
expect($database)->not->toBeNull();
|
||||
|
||||
@@ -25,6 +25,7 @@ use App\Livewire\Project\Service\ResourceCard as ServiceResourceCard;
|
||||
use App\Livewire\Server\Sentinel;
|
||||
use App\Livewire\Server\Show;
|
||||
use App\Models\Environment;
|
||||
use App\Models\InstanceSettings;
|
||||
use App\Models\Project;
|
||||
use App\Models\Server;
|
||||
use App\Models\Service;
|
||||
@@ -40,6 +41,8 @@ use Livewire\Livewire;
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
beforeEach(function () {
|
||||
InstanceSettings::forceCreate(['id' => 0]);
|
||||
|
||||
$this->team = Team::factory()->create();
|
||||
$this->user = User::factory()->create();
|
||||
$this->team->members()->attach($this->user->id, ['role' => 'owner']);
|
||||
@@ -48,6 +51,15 @@ beforeEach(function () {
|
||||
session(['currentTeam' => $this->team]);
|
||||
});
|
||||
|
||||
function sslToggleIsDisabled(string $html): bool
|
||||
{
|
||||
preg_match('/<button[^>]*id="enableSsl-trigger"[^>]*>/s', $html, $matches);
|
||||
|
||||
expect($matches)->not->toBeEmpty();
|
||||
|
||||
return (bool) preg_match('/\sdisabled(\s|=|>)/', $matches[0]);
|
||||
}
|
||||
|
||||
dataset('database-general-forms-without-broadcasts', [
|
||||
// Status-derived display moved into a sibling StatusInfo component for each DB,
|
||||
// so the form itself takes no broadcast listeners and cannot clobber wire:dirty
|
||||
@@ -222,12 +234,15 @@ it('reloads the mysql status-info model when refresh is called so ssl controls f
|
||||
]);
|
||||
|
||||
$component = Livewire::test(MysqlStatusInfo::class, ['database' => $database])
|
||||
->assertDontSee('Database should be stopped to change this settings.');
|
||||
->assertSee('Encryption settings can only be changed while the database is stopped.');
|
||||
|
||||
expect(sslToggleIsDisabled($component->html()))->toBeFalse();
|
||||
|
||||
$database->fill(['status' => 'running:healthy'])->save();
|
||||
|
||||
$component->call('refresh')
|
||||
->assertSee('Database should be stopped to change this settings.');
|
||||
$component->call('refresh');
|
||||
|
||||
expect(sslToggleIsDisabled($component->html()))->toBeTrue();
|
||||
});
|
||||
|
||||
it('shows ssl mode even when ssl is disabled, as a disabled control', function () {
|
||||
@@ -264,7 +279,7 @@ it('does not clobber server form text inputs when sentinel restarts', function (
|
||||
'name' => 'persisted-server-name',
|
||||
]);
|
||||
|
||||
$component = Livewire::test(Sentinel::class, ['server_uuid' => $server->uuid])
|
||||
$component = Livewire::test(Sentinel::class, ['server' => $server])
|
||||
->set('sentinelToken', 'user-was-typing-this-token');
|
||||
|
||||
$component->call('handleSentinelRestarted', ['serverUuid' => $server->uuid]);
|
||||
@@ -308,10 +323,13 @@ it('shows the redis ssl gate hint after the sibling is refreshed', function () {
|
||||
]);
|
||||
|
||||
$component = Livewire::test(RedisStatusInfo::class, ['database' => $database])
|
||||
->assertDontSee('Database should be stopped to change this settings.');
|
||||
->assertSee('Encryption settings can only be changed while the database is stopped.');
|
||||
|
||||
expect(sslToggleIsDisabled($component->html()))->toBeFalse();
|
||||
|
||||
$database->fill(['status' => 'running:healthy'])->save();
|
||||
|
||||
$component->call('refresh')
|
||||
->assertSee('Database should be stopped to change this settings.');
|
||||
$component->call('refresh');
|
||||
|
||||
expect(sslToggleIsDisabled($component->html()))->toBeTrue();
|
||||
});
|
||||
|
||||
@@ -4,6 +4,7 @@ use App\Enums\ApplicationDeploymentStatus;
|
||||
use App\Models\Application;
|
||||
use App\Models\ApplicationDeploymentQueue;
|
||||
use App\Models\Environment;
|
||||
use App\Models\InstanceSettings;
|
||||
use App\Models\Project;
|
||||
use App\Models\Server;
|
||||
use App\Models\Team;
|
||||
@@ -14,6 +15,8 @@ use Illuminate\Support\Str;
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
beforeEach(function () {
|
||||
InstanceSettings::forceCreate(['id' => 0, 'is_api_enabled' => true]);
|
||||
|
||||
$this->team = Team::factory()->create();
|
||||
$this->user = User::factory()->create();
|
||||
$this->team->members()->attach($this->user->id, ['role' => 'owner']);
|
||||
|
||||
@@ -11,7 +11,6 @@ use App\Models\Server;
|
||||
use App\Models\StandaloneDocker;
|
||||
use App\Models\Team;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Support\Facades\Blade;
|
||||
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
@@ -63,46 +62,6 @@ it('filters deployment history by server', function () {
|
||||
->and($result['deployments']->pluck('server_id')->unique()->sole())->toBe($secondServer->id);
|
||||
});
|
||||
|
||||
it('always shows source filters and includes server filters', function () {
|
||||
$component = file_get_contents(app_path('Livewire/Project/Application/Deployment/Index.php'));
|
||||
$view = file_get_contents(resource_path('views/livewire/project/application/deployment/index.blade.php'));
|
||||
$filterComponent = file_get_contents(resource_path('views/components/table/filter.blade.php'));
|
||||
$loadingComponent = file_get_contents(resource_path('views/components/table/loading.blade.php'));
|
||||
|
||||
expect($component)
|
||||
->toContain('public array $serverFilterOptions = [];')
|
||||
->toContain('public array $deploymentFilters = [];')
|
||||
->toContain('public function toggleDeploymentFilter(string $filter): void')
|
||||
->toContain("'value' => \"server:{\$serverId}\"")
|
||||
->and($view)
|
||||
->toContain('@if (count($sourceFilterOptions) > 0)')
|
||||
->toContain('count($serverFilterOptions) > 0')
|
||||
->toContain('>Server</span>')
|
||||
->toContain('<x-table.filter')
|
||||
->toContain("wire:click=\"toggleDeploymentFilter('{{ \$option['value'] }}')\"")
|
||||
->toContain("in_array(\$option['value'], \$deploymentFilters, true)")
|
||||
->toContain('<x-table.loading id="deployment-table-filter-loading"')
|
||||
->not->toContain('class="size-3.5" wire:loading.remove')
|
||||
->not->toContain('<span>All deployments</span>')
|
||||
->and($filterComponent)
|
||||
->toContain('aria-multiselectable="true"')
|
||||
->toContain('Reset filters')
|
||||
->and($loadingComponent)
|
||||
->toContain('wire:loading.flex');
|
||||
});
|
||||
|
||||
it('shows the active pull request id on the deployment filter control', function () {
|
||||
$view = file_get_contents(resource_path('views/livewire/project/application/deployment/index.blade.php'));
|
||||
$filter = Blade::render(<<<'BLADE'
|
||||
<x-table.filter :active-count="1" active-text="Pull request #41" reset-action="clearFilter">
|
||||
Filter options
|
||||
</x-table.filter>
|
||||
BLADE);
|
||||
|
||||
expect($view)->toContain(":active-text=\"filled(\$pull_request_id) ? 'Pull request #'.\$pull_request_id : null\"")
|
||||
->and($filter)->toContain('<span class="truncate">Pull request #41</span>');
|
||||
});
|
||||
|
||||
it('keeps a pull request filter from the URL when it has no deployment records yet', function () {
|
||||
$application = Application::factory()->create();
|
||||
$component = new Index;
|
||||
|
||||
@@ -42,7 +42,7 @@ beforeEach(function () {
|
||||
]);
|
||||
});
|
||||
|
||||
it('renders deployment logs in a full-height layout', function () {
|
||||
it('renders deployment log output', function () {
|
||||
$deployment = ApplicationDeploymentQueue::create([
|
||||
'application_id' => $this->application->id,
|
||||
'deployment_uuid' => 'deploy-layout-test',
|
||||
@@ -70,167 +70,6 @@ it('renders deployment logs in a full-height layout', function () {
|
||||
|
||||
$response->assertSuccessful();
|
||||
$response->assertSee('rolling update started');
|
||||
$response->assertSee('logs-viewer', false);
|
||||
$response->assertSee('logs-viewer-toolbar', false);
|
||||
$response->assertSee('logs-viewer-search', false);
|
||||
$response->assertSee('logs-viewer-actions', false);
|
||||
$response->assertSee('logs-viewer-viewport', false);
|
||||
$response->assertSee('logs-viewer-line', false);
|
||||
$response->assertSee('min-h-[calc(100dvh-7.5rem)] flex-col', false);
|
||||
$response->assertSee('flex flex-1 min-h-0 flex-col overflow-hidden', false);
|
||||
$response->assertSee('h-[calc(100dvh-8rem)]', false);
|
||||
$response->assertSee('xl:h-[32rem]', false);
|
||||
$response->assertSee('xl:flex-none', false);
|
||||
|
||||
expect($response->getContent())
|
||||
->not->toContain('max-h-[30rem]')
|
||||
->not->toContain('xl:h-[calc(100dvh-7.5rem)]')
|
||||
->not->toContain('xl:overflow-hidden')
|
||||
->not->toContain('h-[calc(100dvh-20rem)]')
|
||||
->not->toContain('deployment-logs-panel');
|
||||
});
|
||||
|
||||
it('uses a mobile-friendly stacked logs toolbar markup', function () {
|
||||
$deploymentView = file_get_contents(resource_path('views/livewire/project/application/deployment/show.blade.php'));
|
||||
$sharedLogsView = file_get_contents(resource_path('views/livewire/project/shared/get-logs.blade.php'));
|
||||
$appCss = file_get_contents(resource_path('css/app.css'));
|
||||
|
||||
expect($deploymentView)
|
||||
->toContain('logs-viewer-toolbar')
|
||||
->toContain('logs-viewer-toolbar-controls')
|
||||
->toContain('logs-viewer-primary')
|
||||
->toContain('logs-viewer-meta')
|
||||
->toContain('logs-viewer-end')
|
||||
->toContain('logs-viewer-status-badge')
|
||||
->toContain('logs-viewer-line')
|
||||
->toContain('logs-viewer-timestamp')
|
||||
->toContain('logs-viewer-line-text')
|
||||
->toContain('livewire:project.application.deployment-navbar')
|
||||
->not->toContain('xl:h-[calc(100dvh-7.5rem)]')
|
||||
->not->toContain('xl:overflow-hidden')
|
||||
->toContain('xl:h-[32rem]')
|
||||
->toContain('xl:flex-none')
|
||||
->not->toContain('h-[calc(100dvh-20rem)]')
|
||||
->and($sharedLogsView)
|
||||
->toContain('logs-viewer-toolbar')
|
||||
->toContain('logs-viewer-toolbar-controls')
|
||||
->toContain('logs-viewer-meta')
|
||||
->toContain('logs-viewer-lines')
|
||||
->toContain('logs-viewer-actions')
|
||||
->toContain('logs-viewer-line')
|
||||
->toContain('runtime-log-columns')
|
||||
->toContain('runtime-log-detail')
|
||||
->toContain('runtime-log-empty')
|
||||
->toContain('runtime-log-loading')
|
||||
->toContain('wire:loading.flex wire:target="getLogs"')
|
||||
->toContain('wire:loading.remove wire:target="getLogs"')
|
||||
->toContain('Loading logs')
|
||||
->toContain('Logs will appear here when the container produces output.')
|
||||
->toContain('toggleLogDetails')
|
||||
->toContain('formatLogDetails')
|
||||
->toContain('aria-expanded')
|
||||
->toContain('pl-8!')
|
||||
->toContain('z-10 size-3.5')
|
||||
->and($deploymentView)
|
||||
->toContain('pl-8!')
|
||||
->toContain('z-10 size-3.5')
|
||||
->and($appCss)
|
||||
->toContain('.logs-viewer-toolbar')
|
||||
->toContain('.logs-viewer-end')
|
||||
->toContain('.logs-viewer-status-badge')
|
||||
->toContain('.logs-viewer-lines')
|
||||
->toContain('.logs-viewer-actions')
|
||||
->toContain('.logs-viewer-deployment-actions')
|
||||
->toContain('.logs-settings-section')
|
||||
->toContain('.runtime-log-columns')
|
||||
->toContain('.runtime-log-detail')
|
||||
->toContain('.runtime-log-empty')
|
||||
->toContain('.runtime-log-loading')
|
||||
->toContain('grid-template-columns: var(--runtime-log-columns)')
|
||||
->toContain(".dark .runtime-log-columns {\n background: var(--coollabs-elevated);")
|
||||
->toContain("html[data-theme=\"custom\"] .runtime-log-columns {\n background: var(--color-log-toolbar);")
|
||||
->toContain('padding: 0.5rem 0.75rem 0;')
|
||||
->toContain('padding: 0.5rem 1rem 0;')
|
||||
->toContain(".logs-viewer-viewport::after {\n content: \"\";\n flex: 0 0 2rem;")
|
||||
->toContain('flex-direction: column')
|
||||
->toContain('@media (min-width: 640px)');
|
||||
|
||||
$actionsCss = str($appCss)
|
||||
->after('.logs-viewer-actions {')
|
||||
->before('}')
|
||||
->toString();
|
||||
|
||||
expect($actionsCss)->not->toContain('isolation: isolate');
|
||||
|
||||
$primaryGroup = str($deploymentView)
|
||||
->after('class="logs-viewer-primary"')
|
||||
->before('class="logs-viewer-end"')
|
||||
->toString();
|
||||
$endGroup = str($deploymentView)
|
||||
->after('class="logs-viewer-end"')
|
||||
->before('logs-viewer-viewport')
|
||||
->toString();
|
||||
|
||||
expect($primaryGroup)
|
||||
->toContain('logs-viewer-status-badge')
|
||||
->not->toContain('livewire:project.application.deployment-navbar');
|
||||
|
||||
$timestampPos = strpos($primaryGroup, 'Toggle Timestamps');
|
||||
$followPos = strpos($primaryGroup, 'Follow Logs');
|
||||
$debugPos = strpos($primaryGroup, 'wire:click="toggleDebug"');
|
||||
$fullscreenPos = strpos($primaryGroup, 'title="Fullscreen"');
|
||||
$copyPos = strpos($primaryGroup, 'title="Copy Logs"');
|
||||
$downloadPos = strpos($primaryGroup, 'title="Download Logs"');
|
||||
$badgePos = strpos($primaryGroup, 'logs-viewer-status-badge');
|
||||
|
||||
expect([$timestampPos, $followPos, $debugPos, $fullscreenPos, $copyPos, $downloadPos, $badgePos])
|
||||
->not->toContain(false)
|
||||
->and($timestampPos)->toBeLessThan($followPos)
|
||||
->and($followPos)->toBeLessThan($debugPos)
|
||||
->and($debugPos)->toBeLessThan($fullscreenPos)
|
||||
->and($fullscreenPos)->toBeLessThan($copyPos)
|
||||
->and($copyPos)->toBeLessThan($downloadPos)
|
||||
->and($downloadPos)->toBeLessThan($badgePos)
|
||||
->and($endGroup)->toContain('livewire:project.application.deployment-navbar')
|
||||
->toContain('Find in logs');
|
||||
|
||||
$runtimeActionsPos = strpos($sharedLogsView, 'class="logs-viewer-actions"');
|
||||
$runtimeEndPos = strpos($sharedLogsView, 'class="logs-viewer-end runtime-logs-viewer-end"');
|
||||
$runtimeLinesPos = strpos($sharedLogsView, 'class="logs-viewer-lines"');
|
||||
$runtimeSearchPos = strpos($sharedLogsView, 'placeholder="Find in logs"');
|
||||
|
||||
expect([$runtimeActionsPos, $runtimeEndPos, $runtimeLinesPos, $runtimeSearchPos])
|
||||
->not->toContain(false)
|
||||
->and($runtimeActionsPos)->toBeLessThan($runtimeEndPos)
|
||||
->and($runtimeEndPos)->toBeLessThan($runtimeLinesPos)
|
||||
->and($runtimeLinesPos)->toBeLessThan($runtimeSearchPos)
|
||||
->and($appCss)->toContain('.runtime-logs-viewer-end')
|
||||
->toContain('.dark .runtime-log-icon-button-active')
|
||||
->toContain('background: var(--color-coollabs)')
|
||||
->toContain(".dark .runtime-log-icon-button {\n color: var(--color-fg-faint);")
|
||||
->toContain(".dark .runtime-log-icon-button-active {\n background: var(--color-coollabs);\n color: #fff;")
|
||||
->toContain(".runtime-log-toolbar {\n position: relative;\n z-index: 20;")
|
||||
->and($sharedLogsView)
|
||||
->toContain('runtime-log-icon-button order-1')
|
||||
->toContain('runtime-log-icon-button order-2')
|
||||
->toContain('runtime-log-icon-button order-5')
|
||||
->toContain('runtime-log-icon-button order-6')
|
||||
->toContain('relative order-7 shrink-0');
|
||||
});
|
||||
|
||||
it('keeps deployment history fields and the log status badge accessible on mobile', function () {
|
||||
$deploymentIndexView = file_get_contents(resource_path('views/livewire/project/application/deployment/index.blade.php'));
|
||||
$appCss = file_get_contents(resource_path('css/app.css'));
|
||||
|
||||
expect($deploymentIndexView)
|
||||
->toContain('deployment-table-scroll')
|
||||
->and($appCss)
|
||||
->toContain(".deployment-table-scroll {\n overflow-x: auto;")
|
||||
->toContain(".deployment-table-grid {\n min-width: 59rem;")
|
||||
->toContain("@media (min-width: 1024px) {\n .deployment-table-scroll {\n overflow-x: visible;")
|
||||
->toContain(".deployment-table-grid {\n min-width: 0;")
|
||||
->not->toContain('.deployment-table-grid > :nth-child')
|
||||
->toContain(".logs-viewer-primary .logs-viewer-actions {\n width: auto;\n flex: 1 1 auto;");
|
||||
});
|
||||
|
||||
it('links deployment commit hashes to the source commit page', function () {
|
||||
@@ -266,71 +105,3 @@ it('links deployment commit hashes to the source commit page', function () {
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
it('places cancel deployment controls inside the deployment logs toolbar', function () {
|
||||
$deployment = ApplicationDeploymentQueue::create([
|
||||
'application_id' => $this->application->id,
|
||||
'deployment_uuid' => 'deploy-cancel-toolbar-test',
|
||||
'server_id' => $this->server->id,
|
||||
'status' => ApplicationDeploymentStatus::IN_PROGRESS->value,
|
||||
'logs' => json_encode([
|
||||
[
|
||||
'command' => null,
|
||||
'output' => 'building image',
|
||||
'type' => 'stdout',
|
||||
'timestamp' => now()->toISOString(),
|
||||
'hidden' => false,
|
||||
'batch' => 1,
|
||||
'order' => 1,
|
||||
],
|
||||
], JSON_THROW_ON_ERROR),
|
||||
]);
|
||||
|
||||
$response = $this->get(route('project.application.deployment.show', [
|
||||
'project_uuid' => $this->project->uuid,
|
||||
'environment_uuid' => $this->environment->uuid,
|
||||
'application_uuid' => $this->application->uuid,
|
||||
'deployment_uuid' => $deployment->deployment_uuid,
|
||||
]));
|
||||
|
||||
$response->assertSuccessful();
|
||||
$response->assertSee('Cancel deployment');
|
||||
$response->assertSee('logs-viewer-end', false);
|
||||
$response->assertSee('logs-viewer-deployment-actions', false);
|
||||
$response->assertSee('logs-viewer-cancel-btn', false);
|
||||
|
||||
$content = $response->getContent();
|
||||
$toolbarPos = strpos($content, 'logs-viewer-toolbar');
|
||||
$endPos = strpos($content, 'logs-viewer-end');
|
||||
$viewportPos = strpos($content, 'logs-viewer-viewport');
|
||||
|
||||
expect($toolbarPos)->not->toBeFalse()
|
||||
->and($endPos)->not->toBeFalse()
|
||||
->and($viewportPos)->not->toBeFalse()
|
||||
->and($endPos)->toBeGreaterThan($toolbarPos)
|
||||
->and($endPos)->toBeLessThan($viewportPos);
|
||||
|
||||
// Within the right-side end group: cancel controls, then log search.
|
||||
$endGroup = substr($content, $endPos, $viewportPos - $endPos);
|
||||
$cancelPos = strpos($endGroup, 'Cancel deployment');
|
||||
$searchPos = strpos($endGroup, 'Find in logs');
|
||||
|
||||
expect($cancelPos)->not->toBeFalse()
|
||||
->and($searchPos)->not->toBeFalse()
|
||||
->and($cancelPos)->toBeLessThan($searchPos)
|
||||
->and($content)->toContain('In progress');
|
||||
});
|
||||
|
||||
it('uses the shared mobile logs layout for proxy and sentinel log pages', function () {
|
||||
$proxyLogsView = file_get_contents(resource_path('views/livewire/server/proxy/logs.blade.php'));
|
||||
$sentinelLogsView = file_get_contents(resource_path('views/livewire/server/sentinel/logs.blade.php'));
|
||||
|
||||
expect($proxyLogsView)
|
||||
->toContain('logs-settings-section')
|
||||
->toContain('logs-section-status-badge')
|
||||
->toContain('livewire:project.shared.get-logs')
|
||||
->and($sentinelLogsView)
|
||||
->toContain('logs-settings-section')
|
||||
->toContain('logs-section-status-badge')
|
||||
->toContain('livewire:project.shared.get-logs');
|
||||
});
|
||||
|
||||
@@ -2,22 +2,30 @@
|
||||
|
||||
use Symfony\Component\Process\Process;
|
||||
|
||||
it('documents example nginx connectivity commands', function () {
|
||||
$process = new Process(['bash', base_path('scripts/dev.sh'), 'example-nginx', 'help'], base_path());
|
||||
/*
|
||||
* The V5 `scripts/dev.sh` (including its `example-nginx` helper) was archived in
|
||||
* docs/v5/archive/scripts/dev.sh.txt and replaced by `scripts/dev`. These tests make
|
||||
* sure the current dev script does not document or wire the removed firewall CLI.
|
||||
*/
|
||||
|
||||
it('documents dev script commands without the removed firewall commands', function () {
|
||||
$process = new Process(['bash', base_path('scripts/dev'), 'help'], base_path());
|
||||
|
||||
$process->run();
|
||||
|
||||
expect($process->isSuccessful())->toBeTrue()
|
||||
->and($process->getOutput())->toContain('ping')
|
||||
->and($process->getOutput())->toContain('./scripts/dev start')
|
||||
->and($process->getOutput())->not->toContain('firewall up')
|
||||
->and($process->getOutput())->not->toContain('firewall down')
|
||||
->and($process->getOutput())->not->toContain('firewall-up')
|
||||
->and($process->getOutput())->not->toContain('firewall-down');
|
||||
});
|
||||
|
||||
it('does not wire example nginx to removed firewall CLI commands', function () {
|
||||
$script = file_get_contents(base_path('scripts/dev.sh'));
|
||||
it('does not wire the dev script to removed firewall CLI commands', function () {
|
||||
expect(file_exists(base_path('scripts/dev.sh')))->toBeFalse();
|
||||
|
||||
expect($script)->not->toContain('scripts/dev.sh firewall allow "$src" "$dst" tcp 80')
|
||||
->and($script)->not->toContain('scripts/dev.sh firewall revoke "$src" "$dst" tcp 80');
|
||||
$script = file_get_contents(base_path('scripts/dev'));
|
||||
|
||||
expect($script)->not->toContain('firewall allow')
|
||||
->and($script)->not->toContain('firewall revoke');
|
||||
});
|
||||
|
||||
@@ -161,10 +161,14 @@ it('deletes the droplet when local server persistence fails', function () {
|
||||
&& $request->url() === 'https://api.digitalocean.com/v2/droplets/555');
|
||||
});
|
||||
|
||||
it('renders only the full width buy button at the bottom of the DigitalOcean form', function () {
|
||||
Livewire::test(ByDigitalOcean::class)
|
||||
it('renders only a single buy button as the DigitalOcean form action', function () {
|
||||
$html = Livewire::test(ByDigitalOcean::class)
|
||||
->set('current_step', 2)
|
||||
->assertDontSee('wire:click="previousStep"', false)
|
||||
->assertSeeHtml('class="button w-full"')
|
||||
->assertSee('Buy & Create Server', false);
|
||||
->assertSeeHtml('class="button button-highlighted"')
|
||||
->assertSee('Buy and create')
|
||||
->html();
|
||||
|
||||
expect(substr_count($html, 'type="submit"'))->toBe(1)
|
||||
->and(substr_count($html, 'Buy and create'))->toBe(1);
|
||||
});
|
||||
|
||||
@@ -302,13 +302,6 @@ test('an unchanged managed record is deleted from cloudflare and coolify', funct
|
||||
->and(ManagedDnsRecord::query()->find($record->id))->toBeNull();
|
||||
});
|
||||
|
||||
test('dns provider modal view always has a single root element', function () {
|
||||
$providerModal = file_get_contents(resource_path('views/livewire/project/shared/dns-provider-management.blade.php'));
|
||||
|
||||
expect(ltrim($providerModal))->toStartWith('<div class="contents">')
|
||||
->and($providerModal)->toContain('@if ($showDnsProviderModal)');
|
||||
});
|
||||
|
||||
describe('domain DNS configuration after add', function () {
|
||||
beforeEach(function () {
|
||||
$this->withoutVite();
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
use App\Models\Application;
|
||||
use App\Models\ApplicationPreview;
|
||||
use App\Models\Environment;
|
||||
use App\Models\InstanceSettings;
|
||||
use App\Models\Project;
|
||||
use App\Models\Server;
|
||||
use App\Models\StandaloneDocker;
|
||||
@@ -16,6 +17,7 @@ uses(RefreshDatabase::class);
|
||||
|
||||
beforeEach(function () {
|
||||
Queue::fake();
|
||||
InstanceSettings::forceCreate(['id' => 0]);
|
||||
|
||||
$this->team = Team::factory()->create();
|
||||
$this->user = User::factory()->create();
|
||||
@@ -133,6 +135,12 @@ test('it rejects docker_tag for non docker image applications', function () {
|
||||
'build_pack' => 'nixpacks',
|
||||
]);
|
||||
|
||||
ApplicationPreview::create([
|
||||
'application_id' => $application->id,
|
||||
'pull_request_id' => 7,
|
||||
'pull_request_html_url' => '',
|
||||
]);
|
||||
|
||||
$response = $this->withHeaders([
|
||||
'Authorization' => 'Bearer '.$this->bearerToken,
|
||||
])->postJson('/api/v1/deploy', [
|
||||
|
||||
@@ -1,75 +1,5 @@
|
||||
<?php
|
||||
|
||||
test('edit domain dialogs close after saving and omit redundant cancel actions', function () {
|
||||
foreach ([
|
||||
resource_path('views/livewire/project/application/domains.blade.php'),
|
||||
resource_path('views/livewire/project/service/domains.blade.php'),
|
||||
] as $viewPath) {
|
||||
$view = file_get_contents($viewPath);
|
||||
|
||||
expect($view)
|
||||
->toContain('@edit-domain-saved.window="closeEditDomain()"')
|
||||
->toContain('<x-forms.button type="submit" wire:target="updateDomain" isHighlighted>')
|
||||
->not->toContain("@click=\"closeEditDomain()\">\n Cancel");
|
||||
}
|
||||
});
|
||||
|
||||
it('provides a reusable domain chips form component', function () {
|
||||
$component = file_get_contents(resource_path('views/components/forms/domain-chips.blade.php'));
|
||||
|
||||
expect($component)
|
||||
->toContain('chip-input')
|
||||
->toContain('@entangle($model)')
|
||||
->toContain('class="chip"')
|
||||
->toContain('chip-remove');
|
||||
});
|
||||
|
||||
it('uses domain chips for application public access and compose services', function () {
|
||||
$blade = file_get_contents(resource_path('views/livewire/project/application/general.blade.php'));
|
||||
|
||||
expect($blade)
|
||||
->toContain('x-forms.domain-chips')
|
||||
->toContain('model="fqdn"')
|
||||
->toContain(':model="$composeDomainKey"')
|
||||
->not->toContain('id="parsedServiceDomains.');
|
||||
});
|
||||
|
||||
it('uses domain chips for service application domains and edit domain modal', function () {
|
||||
$serviceIndex = file_get_contents(resource_path('views/livewire/project/service/index.blade.php'));
|
||||
$editDomain = file_get_contents(resource_path('views/livewire/project/service/edit-domain.blade.php'));
|
||||
|
||||
expect($serviceIndex)
|
||||
->toContain('x-forms.domain-chips')
|
||||
->toContain('model="fqdn"')
|
||||
->toContain('Make publicly available')
|
||||
->toContain('enablePublicAccess')
|
||||
->toContain('disablePublicAccess')
|
||||
->toContain("x-bind:disabled=\"!String(port ?? '').trim()\"")
|
||||
->toContain('sm:flex-row sm:items-end')
|
||||
->not->toContain('label="Make it publicly available"')
|
||||
// Nested @if inside <x-forms.button> breaks Blade component compilation (ParseError endif).
|
||||
->not->toContain('@if ($canTogglePublicAccess)');
|
||||
|
||||
expect($editDomain)
|
||||
->toContain('x-forms.domain-chips')
|
||||
->toContain('model="fqdn"')
|
||||
->not->toContain('id="fqdn"');
|
||||
});
|
||||
|
||||
it('styles icon buttons with a visible hover state', function () {
|
||||
$utilities = file_get_contents(resource_path('css/utilities.css'));
|
||||
$resourceCard = file_get_contents(resource_path('views/livewire/project/service/resource-card.blade.php'));
|
||||
|
||||
expect($utilities)
|
||||
->toContain('@utility icon-button')
|
||||
->toContain('hover:bg-neutral-100')
|
||||
->toContain('dark:hover:bg-white/[0.07]');
|
||||
|
||||
expect($resourceCard)
|
||||
->toContain('class="icon-button"')
|
||||
->toContain('title="Manage domains"');
|
||||
});
|
||||
|
||||
it('exposes enable and disable public access methods on service index', function () {
|
||||
$source = file_get_contents(app_path('Livewire/Project/Service/Index.php'));
|
||||
|
||||
|
||||
@@ -1,98 +1,5 @@
|
||||
<?php
|
||||
|
||||
it('renders the dashed empty-state card with title description and icon', function () {
|
||||
$html = $this->blade(
|
||||
'<x-empty title="No tags yet" description="Add a tag to group deployments." icon-name="tags" />'
|
||||
);
|
||||
|
||||
$html->assertSee('No tags yet')
|
||||
->assertSee('Add a tag to group deployments.')
|
||||
->assertSee('empty-state', false)
|
||||
->assertSee('border-dashed', false)
|
||||
->assertSee('min-h-80', false);
|
||||
});
|
||||
|
||||
it('renders empty states flush with no outer inset inside settings sections', function () {
|
||||
$html = $this->blade(
|
||||
<<<'BLADE'
|
||||
<x-application.settings-section title="Scheduled tasks" flush>
|
||||
<x-empty title="No scheduled tasks"
|
||||
description="Create a task to run maintenance commands."
|
||||
icon-name="browser-terminal" />
|
||||
</x-application.settings-section>
|
||||
BLADE
|
||||
);
|
||||
|
||||
$html->assertSee('is-flush', false)
|
||||
->assertSee('empty-state', false)
|
||||
->assertSee('No scheduled tasks');
|
||||
|
||||
$css = file_get_contents(resource_path('css/app.css'));
|
||||
|
||||
expect($css)
|
||||
->toContain('.application-settings-section-body.is-flush .empty-state')
|
||||
->toContain('.application-settings-section-body:has(> .empty-state:only-child)')
|
||||
->toContain('.application-settings-section > .empty-state')
|
||||
->toMatch('/\.application-settings-section-body\.is-flush \.empty-state[\s\S]*?margin:\s*0/');
|
||||
});
|
||||
|
||||
it('insets empty states nested inside a flush settings section wrapper', function () {
|
||||
$css = file_get_contents(resource_path('css/app.css'));
|
||||
|
||||
expect($css)->toContain('.application-settings-section-body.is-flush > div:has(> .empty-state:only-child)');
|
||||
});
|
||||
|
||||
it('renders the backup executions empty state without a scrollable table', function () {
|
||||
$view = file_get_contents(resource_path('views/livewire/project/database/backup-executions.blade.php'));
|
||||
|
||||
expect($view)
|
||||
->toMatch('/@if \(\$executions_count === 0\)\s*<div class="p-4">\s*<x-empty size="sm" title="No backup executions"/')
|
||||
->toMatch('/@else\s*<div class="data-table deployment-table-scroll backup-executions-table-scroll">/')
|
||||
->not->toContain('@forelse ($executions as $execution)');
|
||||
});
|
||||
|
||||
it('keeps backup execution actions compact', function () {
|
||||
$css = file_get_contents(resource_path('css/app.css'));
|
||||
$view = file_get_contents(resource_path('views/livewire/project/database/backup-executions.blade.php'));
|
||||
|
||||
expect($css)
|
||||
->toContain('grid-template-columns: 6.5rem minmax(7rem, 0.8fr) minmax(14rem, 1.5fr) 7rem 5rem 4rem minmax(8rem, 1fr) 5rem;')
|
||||
->and($view)
|
||||
->toContain('<span class="text-right">Actions</span>')
|
||||
->toContain('class="flex items-center justify-end gap-1"')
|
||||
->not->toContain('sticky right-0')
|
||||
->toContain('title="Download backup" aria-label="Download backup"')
|
||||
->toContain('<x-reicon name="upload" class="size-3.5 rotate-180" />')
|
||||
->toContain('title="Delete backup" aria-label="Delete backup"')
|
||||
->toContain('<x-reicon name="trash" class="size-3.5" />');
|
||||
});
|
||||
|
||||
it('uses the compact resource table styling for backup executions', function () {
|
||||
$view = file_get_contents(resource_path('views/livewire/project/database/backup-executions.blade.php'));
|
||||
$css = file_get_contents(resource_path('css/app.css'));
|
||||
|
||||
expect($view)
|
||||
->toContain('class="data-table deployment-table-scroll backup-executions-table-scroll"')
|
||||
->toContain('data-table-header backup-executions-table-grid h-auto rounded-none px-4 py-2.5 text-[11px]')
|
||||
->toContain('data-table-row backup-executions-table-grid min-h-14 border-b border-neutral-200 px-4 py-2.5 dark:border-white/[0.06]')
|
||||
->toContain('class="backup-execution-row"')
|
||||
->toContain('flex min-h-11 items-center justify-between border-t')
|
||||
->and($css)
|
||||
->toMatch('/\.backup-executions-table-scroll\s*\{[^}]*overflow-x:\s*auto;/')
|
||||
->toMatch('/\.backup-execution-row:last-child > \.data-table-row\s*\{[^}]*border-bottom:\s*0;/')
|
||||
->toMatch('/\.backup-executions-table-grid\s*\{[^}]*gap:\s*0\.75rem;[^}]*min-width:\s*66rem;/');
|
||||
});
|
||||
|
||||
it('renders compact size without the full-page min height class', function () {
|
||||
$html = $this->blade(
|
||||
'<x-empty title="No cloud tokens" description="Add a provider token." icon-name="keys" size="sm" />'
|
||||
);
|
||||
|
||||
$html->assertSee('No cloud tokens')
|
||||
->assertSee('min-h-44', false)
|
||||
->assertDontSee('min-h-80', false);
|
||||
});
|
||||
|
||||
it('renders action content from both contents and actions slots', function () {
|
||||
$contents = $this->blade(
|
||||
'<x-empty title="Empty" icon-name="keys"><x-slot:contents><button type="button">From contents</button></x-slot:contents></x-empty>'
|
||||
|
||||
@@ -47,17 +47,6 @@ beforeEach(function () {
|
||||
Once::flush();
|
||||
});
|
||||
|
||||
it('renders settings email enable actions instead of enabled checkboxes', function () {
|
||||
$view = file_get_contents(resource_path('views/livewire/settings-email.blade.php'));
|
||||
|
||||
expect($view)->toContain('Enable SMTP Server')
|
||||
->and($view)->toContain('Disable SMTP Server')
|
||||
->and($view)->toContain('Enable Resend')
|
||||
->and($view)->toContain('Disable Resend')
|
||||
->and($view)->not->toContain('id="smtpEnabled" label="Enabled"')
|
||||
->and($view)->not->toContain('id="resendEnabled" label="Enabled"');
|
||||
});
|
||||
|
||||
it('keeps transactional smtp disabled when enable validation fails', function () {
|
||||
actingAsEnableActionInstanceAdmin();
|
||||
|
||||
@@ -87,19 +76,6 @@ it('enables transactional smtp only after required fields validate', function ()
|
||||
->and(instanceSettings()->fresh()->resend_enabled)->toBeFalse();
|
||||
});
|
||||
|
||||
it('renders notification provider enable actions instead of enabled checkboxes', function (string $view, string $enableLabel, string $checkboxSnippet) {
|
||||
$contents = file_get_contents(resource_path("views/livewire/notifications/{$view}.blade.php"));
|
||||
|
||||
expect($contents)->toContain($enableLabel)
|
||||
->and($contents)->not->toContain($checkboxSnippet);
|
||||
})->with([
|
||||
'discord' => ['discord', 'Enable Discord', 'id="discordEnabled" label="Enabled"'],
|
||||
'slack' => ['slack', 'Enable Slack', 'id="slackEnabled" label="Enabled"'],
|
||||
'telegram' => ['telegram', 'Enable Telegram', 'id="telegramEnabled" label="Enabled"'],
|
||||
'pushover' => ['pushover', 'Enable Pushover', 'id="pushoverEnabled" label="Enabled"'],
|
||||
'webhook' => ['webhook', 'Enable Webhook', 'id="webhookEnabled" label="Enabled"'],
|
||||
]);
|
||||
|
||||
it('shows notification provider save buttons while disabled', function (string $component) {
|
||||
actingAsEnableActionOwner();
|
||||
|
||||
@@ -117,12 +93,15 @@ it('shows notification provider save buttons while disabled', function (string $
|
||||
it('hides notification provider test buttons while disabled and shows them when enabled', function (string $component, string $enabledProperty) {
|
||||
actingAsEnableActionOwner();
|
||||
|
||||
Livewire::test($component)
|
||||
->assertDontSee('Send Test Notification');
|
||||
// The channel actions always render "Send test"; it stays disabled until the channel is enabled.
|
||||
$sendTestButton = '/<button\s+(disabled\s+)?class="button"[^>]*\$wire\.\$call\(testMethod\)[^>]*>/';
|
||||
|
||||
Livewire::test($component)
|
||||
->set($enabledProperty, true)
|
||||
->assertSee('Send Test Notification');
|
||||
preg_match($sendTestButton, Livewire::test($component)->html(), $disabledMatch);
|
||||
expect($disabledMatch[1] ?? null)->not->toBeEmpty();
|
||||
|
||||
preg_match($sendTestButton, Livewire::test($component)->set($enabledProperty, true)->html(), $enabledMatch);
|
||||
expect($enabledMatch)->not->toBeEmpty()
|
||||
->and($enabledMatch[1] ?? '')->toBe('');
|
||||
})->with([
|
||||
'discord' => [Discord::class, 'discordEnabled'],
|
||||
'slack' => [Slack::class, 'slackEnabled'],
|
||||
@@ -155,20 +134,6 @@ it('keeps notification providers disabled when enable validation fails', functio
|
||||
'webhook' => [Webhook::class, 'toggleWebhookEnabled', 'webhookEnabled', 'webhookUrl', 'webhookNotificationSettings', 'webhook_enabled'],
|
||||
]);
|
||||
|
||||
it('renders notification email and log drain enable actions instead of enabled checkboxes', function () {
|
||||
$notificationEmail = file_get_contents(resource_path('views/livewire/notifications/email.blade.php'));
|
||||
$logDrains = file_get_contents(resource_path('views/livewire/server/log-drains.blade.php'));
|
||||
|
||||
expect($notificationEmail)->toContain('Enable SMTP Server')
|
||||
->and($notificationEmail)->toContain('Enable Resend')
|
||||
->and($notificationEmail)->not->toContain('id="smtpEnabled"')
|
||||
->and($notificationEmail)->not->toContain('id="resendEnabled"')
|
||||
->and($logDrains)->toContain('Enable New Relic')
|
||||
->and($logDrains)->toContain('Enable Axiom')
|
||||
->and($logDrains)->toContain('Enable Custom FluentBit')
|
||||
->and($logDrains)->not->toContain('label="Enabled"');
|
||||
});
|
||||
|
||||
it('keeps notification email smtp disabled when enable validation fails', function () {
|
||||
actingAsEnableActionOwner();
|
||||
|
||||
|
||||
@@ -1,64 +1,15 @@
|
||||
<?php
|
||||
|
||||
it('uses the current listbox design for environment variable suggestions', function () {
|
||||
$view = file_get_contents(resource_path('views/components/forms/env-var-input.blade.php'));
|
||||
|
||||
expect($view)
|
||||
->toContain('class="listbox-panel')
|
||||
->toContain('class="listbox-option justify-start! gap-2.5!"')
|
||||
->toContain("'bg-neutral-100 dark:bg-white/[0.08]': index === selectedIndex")
|
||||
->toContain('border-warning/25 bg-warning/10')
|
||||
->toContain('border-emerald-500/25 bg-emerald-500/10')
|
||||
->not->toContain('dark:bg-coolgray-100');
|
||||
});
|
||||
|
||||
it('keeps the environment variable input enabled while secret manager keys load', function () {
|
||||
$view = file_get_contents(resource_path('views/components/forms/env-var-input.blade.php'));
|
||||
|
||||
expect($view)->toContain('wire:target.except="fetchSecretManagerKeys"');
|
||||
});
|
||||
|
||||
it('allows secret manager key loading to retry after a failed request', function () {
|
||||
$view = file_get_contents(resource_path('views/components/forms/env-var-input.blade.php'));
|
||||
$failureHandler = explode('});', explode('.catch(() => {', $view, 2)[1], 2)[0];
|
||||
|
||||
expect($failureHandler)
|
||||
->toContain('this.vaultKeysLoading = false;')
|
||||
->not->toContain("this.availableVars['vault'] = [];");
|
||||
});
|
||||
|
||||
it('authorizes secret-enabled environment variable inputs at the component boundary', function () {
|
||||
$addView = file_get_contents(resource_path('views/livewire/project/shared/environment-variable/add.blade.php'));
|
||||
$showView = file_get_contents(resource_path('views/livewire/project/shared/environment-variable/show.blade.php'));
|
||||
|
||||
foreach ([$addView, $showView] as $view) {
|
||||
// Show resolves the resource through its computed `resource` property.
|
||||
foreach ([[$addView, '$resource'], [$showView, '$this->resource']] as [$view, $resourceExpression]) {
|
||||
preg_match('/<x-forms\.env-var-input[\s\S]*?\/>/', $view, $matches);
|
||||
|
||||
expect($matches[0] ?? '')
|
||||
->toContain('canGate="manageEnvironment"')
|
||||
->toContain(':canResource="$resource"');
|
||||
}
|
||||
});
|
||||
|
||||
it('passes the remove source warning without compiling remote secret syntax as blade', function () {
|
||||
$view = file_get_contents(resource_path('views/livewire/project/shared/secret-manager-links.blade.php'));
|
||||
|
||||
expect($view)
|
||||
->toContain('with {{vault.KEY}}. Values are fetched')
|
||||
->not->toContain('{{doppler.KEY}}')
|
||||
->not->toContain('{{infisical.KEY}}')
|
||||
->toContain(':actions="[$removeSourceWarning]"')
|
||||
->not->toContain(':actions="[\'Existing {{vault.*}}');
|
||||
});
|
||||
|
||||
it('shows secret manager configuration for applications services and databases', function () {
|
||||
$views = [
|
||||
resource_path('views/livewire/project/application/configuration.blade.php'),
|
||||
resource_path('views/livewire/project/service/configuration.blade.php'),
|
||||
resource_path('views/livewire/project/database/configuration.blade.php'),
|
||||
];
|
||||
|
||||
foreach ($views as $view) {
|
||||
expect(file_get_contents($view))->toContain('livewire:project.shared.secret-manager-links');
|
||||
->toContain(':canResource="'.$resourceExpression.'"');
|
||||
}
|
||||
});
|
||||
|
||||
@@ -33,8 +33,8 @@ test('shared variable preserves spacing in reference', function () {
|
||||
$env = EnvironmentVariable::create([
|
||||
'key' => 'TEST_VAR',
|
||||
'value' => '{{ project.aaa }}',
|
||||
'resource_id' => $this->application->id,
|
||||
'resource_type' => $this->application->getMorphClass(),
|
||||
'resourceable_id' => $this->application->id,
|
||||
'resourceable_type' => $this->application->getMorphClass(),
|
||||
]);
|
||||
|
||||
$env->refresh();
|
||||
@@ -45,8 +45,8 @@ test('shared variable preserves no-space format', function () {
|
||||
$env = EnvironmentVariable::create([
|
||||
'key' => 'TEST_VAR',
|
||||
'value' => '{{project.aaa}}',
|
||||
'resource_id' => $this->application->id,
|
||||
'resource_type' => $this->application->getMorphClass(),
|
||||
'resourceable_id' => $this->application->id,
|
||||
'resourceable_type' => $this->application->getMorphClass(),
|
||||
]);
|
||||
|
||||
$env->refresh();
|
||||
@@ -67,8 +67,8 @@ test('shared variable with spaces resolves correctly', function () {
|
||||
$env = EnvironmentVariable::create([
|
||||
'key' => 'MY_VAR',
|
||||
'value' => '{{ project.TEST_KEY }}',
|
||||
'resource_id' => $this->application->id,
|
||||
'resource_type' => $this->application->getMorphClass(),
|
||||
'resourceable_id' => $this->application->id,
|
||||
'resourceable_type' => $this->application->getMorphClass(),
|
||||
]);
|
||||
|
||||
// Verify it resolves correctly
|
||||
@@ -90,8 +90,8 @@ test('shared variable without spaces resolves correctly', function () {
|
||||
$env = EnvironmentVariable::create([
|
||||
'key' => 'MY_VAR',
|
||||
'value' => '{{project.TEST_KEY}}',
|
||||
'resource_id' => $this->application->id,
|
||||
'resource_type' => $this->application->getMorphClass(),
|
||||
'resourceable_id' => $this->application->id,
|
||||
'resourceable_type' => $this->application->getMorphClass(),
|
||||
]);
|
||||
|
||||
// Verify it resolves correctly
|
||||
@@ -113,8 +113,8 @@ test('shared variable with extra internal spaces resolves correctly', function (
|
||||
$env = EnvironmentVariable::create([
|
||||
'key' => 'MY_VAR',
|
||||
'value' => '{{ project.TEST_KEY }}',
|
||||
'resource_id' => $this->application->id,
|
||||
'resource_type' => $this->application->getMorphClass(),
|
||||
'resourceable_id' => $this->application->id,
|
||||
'resourceable_type' => $this->application->getMorphClass(),
|
||||
]);
|
||||
|
||||
// Verify it resolves correctly (parser trims when extracting)
|
||||
@@ -126,8 +126,8 @@ test('is_shared attribute detects variable with spaces', function () {
|
||||
$env = EnvironmentVariable::create([
|
||||
'key' => 'TEST',
|
||||
'value' => '{{ project.aaa }}',
|
||||
'resource_id' => $this->application->id,
|
||||
'resource_type' => $this->application->getMorphClass(),
|
||||
'resourceable_id' => $this->application->id,
|
||||
'resourceable_type' => $this->application->getMorphClass(),
|
||||
]);
|
||||
|
||||
expect($env->is_shared)->toBeTrue();
|
||||
@@ -137,8 +137,8 @@ test('is_shared attribute detects variable without spaces', function () {
|
||||
$env = EnvironmentVariable::create([
|
||||
'key' => 'TEST',
|
||||
'value' => '{{project.aaa}}',
|
||||
'resource_id' => $this->application->id,
|
||||
'resource_type' => $this->application->getMorphClass(),
|
||||
'resourceable_id' => $this->application->id,
|
||||
'resourceable_type' => $this->application->getMorphClass(),
|
||||
]);
|
||||
|
||||
expect($env->is_shared)->toBeTrue();
|
||||
@@ -148,8 +148,8 @@ test('is_shared persisted value rejects unsupported reference types', function (
|
||||
$env = EnvironmentVariable::create([
|
||||
'key' => 'TEST',
|
||||
'value' => '{{vault.KEY}}',
|
||||
'resource_id' => $this->application->id,
|
||||
'resource_type' => $this->application->getMorphClass(),
|
||||
'resourceable_id' => $this->application->id,
|
||||
'resourceable_type' => $this->application->getMorphClass(),
|
||||
]);
|
||||
|
||||
$env->refresh();
|
||||
@@ -165,8 +165,8 @@ test('non-shared variable preserves spaces', function () {
|
||||
$env = EnvironmentVariable::create([
|
||||
'key' => 'REGULAR',
|
||||
'value' => 'regular value with spaces',
|
||||
'resource_id' => $this->application->id,
|
||||
'resource_type' => $this->application->getMorphClass(),
|
||||
'resourceable_id' => $this->application->id,
|
||||
'resourceable_type' => $this->application->getMorphClass(),
|
||||
]);
|
||||
|
||||
$env->refresh();
|
||||
@@ -177,8 +177,8 @@ test('mixed content with shared variable preserves all spacing', function () {
|
||||
$env = EnvironmentVariable::create([
|
||||
'key' => 'MIXED',
|
||||
'value' => 'prefix {{ project.aaa }} suffix',
|
||||
'resource_id' => $this->application->id,
|
||||
'resource_type' => $this->application->getMorphClass(),
|
||||
'resourceable_id' => $this->application->id,
|
||||
'resourceable_type' => $this->application->getMorphClass(),
|
||||
]);
|
||||
|
||||
$env->refresh();
|
||||
@@ -189,8 +189,8 @@ test('multiple shared variables preserve individual spacing', function () {
|
||||
$env = EnvironmentVariable::create([
|
||||
'key' => 'MULTI',
|
||||
'value' => '{{ project.a }} and {{team.b}}',
|
||||
'resource_id' => $this->application->id,
|
||||
'resource_type' => $this->application->getMorphClass(),
|
||||
'resourceable_id' => $this->application->id,
|
||||
'resourceable_type' => $this->application->getMorphClass(),
|
||||
]);
|
||||
|
||||
$env->refresh();
|
||||
@@ -201,8 +201,8 @@ test('leading and trailing spaces are trimmed', function () {
|
||||
$env = EnvironmentVariable::create([
|
||||
'key' => 'TRIMMED',
|
||||
'value' => ' {{ project.aaa }} ',
|
||||
'resource_id' => $this->application->id,
|
||||
'resource_type' => $this->application->getMorphClass(),
|
||||
'resourceable_id' => $this->application->id,
|
||||
'resourceable_type' => $this->application->getMorphClass(),
|
||||
]);
|
||||
|
||||
$env->refresh();
|
||||
|
||||
@@ -15,7 +15,7 @@ use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
beforeEach(function () {
|
||||
InstanceSettings::updateOrCreate(['id' => 0]);
|
||||
InstanceSettings::forceCreate(['id' => 0]);
|
||||
|
||||
$this->team = Team::factory()->create();
|
||||
$this->user = User::factory()->create();
|
||||
|
||||
@@ -1,19 +1,43 @@
|
||||
<?php
|
||||
|
||||
use App\Livewire\Project\Shared\EnvironmentVariable\All;
|
||||
use App\Livewire\Project\Shared\EnvironmentVariable\Show;
|
||||
use App\Models\Application;
|
||||
use App\Models\Environment;
|
||||
use App\Models\EnvironmentVariable;
|
||||
use App\Models\InstanceSettings;
|
||||
use App\Models\Project;
|
||||
use App\Models\Server;
|
||||
use App\Models\StandaloneDocker;
|
||||
use App\Models\Team;
|
||||
use App\Models\User;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
beforeEach(function () {
|
||||
InstanceSettings::unguarded(function () {
|
||||
InstanceSettings::updateOrCreate(['id' => 0], []);
|
||||
});
|
||||
|
||||
$this->user = User::factory()->create();
|
||||
$this->team = Team::factory()->create();
|
||||
$this->team->members()->attach($this->user, ['role' => 'owner']);
|
||||
$this->application = Application::factory()->create([
|
||||
'team_id' => $this->team->id,
|
||||
]);
|
||||
|
||||
$this->actingAs($this->user);
|
||||
session(['currentTeam' => $this->team]);
|
||||
|
||||
$project = Project::factory()->create(['team_id' => $this->team->id]);
|
||||
$environment = Environment::factory()->create(['project_id' => $project->id]);
|
||||
$server = Server::factory()->create(['team_id' => $this->team->id]);
|
||||
$destination = StandaloneDocker::where('server_id', $server->id)->first()
|
||||
?? StandaloneDocker::factory()->create(['server_id' => $server->id, 'network' => 'coolify-test']);
|
||||
|
||||
$this->application = Application::factory()->create([
|
||||
'environment_id' => $environment->id,
|
||||
'destination_id' => $destination->id,
|
||||
'destination_type' => StandaloneDocker::class,
|
||||
]);
|
||||
});
|
||||
|
||||
test('environment variable can be created with comment', function () {
|
||||
@@ -143,10 +167,12 @@ test('environment variable comment cannot exceed 256 characters via Livewire', f
|
||||
|
||||
$longComment = str_repeat('a', 257);
|
||||
|
||||
Livewire::test(\App\Livewire\Project\Shared\EnvironmentVariable\Show::class, ['env' => $env, 'type' => 'application'])
|
||||
Livewire::test(Show::class, ['env' => $env, 'type' => 'application'])
|
||||
->set('comment', $longComment)
|
||||
->call('submit')
|
||||
->assertHasErrors(['comment' => 'max']);
|
||||
|
||||
expect($env->fresh()->comment)->toBeNull();
|
||||
});
|
||||
|
||||
test('bulk update preserves existing comments when no inline comment provided', function () {
|
||||
@@ -162,7 +188,7 @@ test('bulk update preserves existing comments when no inline comment provided',
|
||||
// User switches to Developer view and pastes new value without inline comment
|
||||
$bulkContent = "DATABASE_URL=postgres://new-host\nOTHER_VAR=value";
|
||||
|
||||
Livewire::test(\App\Livewire\Project\Shared\EnvironmentVariable\All::class, [
|
||||
Livewire::test(All::class, [
|
||||
'resource' => $this->application,
|
||||
'type' => 'application',
|
||||
])
|
||||
@@ -192,7 +218,7 @@ test('bulk update overwrites existing comments when inline comment provided', fu
|
||||
// User pastes new value WITH inline comment
|
||||
$bulkContent = 'API_KEY=new-key #Updated production key';
|
||||
|
||||
Livewire::test(\App\Livewire\Project\Shared\EnvironmentVariable\All::class, [
|
||||
Livewire::test(All::class, [
|
||||
'resource' => $this->application,
|
||||
'type' => 'application',
|
||||
])
|
||||
@@ -230,7 +256,7 @@ test('bulk update handles mixed inline and stored comments correctly', function
|
||||
// Bulk paste: one with inline comment, one without
|
||||
$bulkContent = "VAR_WITH_COMMENT=new_value1 #New inline comment\nVAR_WITHOUT_COMMENT=new_value2";
|
||||
|
||||
Livewire::test(\App\Livewire\Project\Shared\EnvironmentVariable\All::class, [
|
||||
Livewire::test(All::class, [
|
||||
'resource' => $this->application,
|
||||
'type' => 'application',
|
||||
])
|
||||
@@ -254,7 +280,7 @@ test('bulk update creates new variables with inline comments', function () {
|
||||
// Bulk paste creates new variables, some with inline comments
|
||||
$bulkContent = "NEW_VAR1=value1 #Comment for var1\nNEW_VAR2=value2\nNEW_VAR3=value3 #Comment for var3";
|
||||
|
||||
Livewire::test(\App\Livewire\Project\Shared\EnvironmentVariable\All::class, [
|
||||
Livewire::test(All::class, [
|
||||
'resource' => $this->application,
|
||||
'type' => 'application',
|
||||
])
|
||||
|
||||
@@ -4,6 +4,9 @@ use App\Models\Application;
|
||||
use App\Models\EnvironmentVariable;
|
||||
use App\Models\Team;
|
||||
use App\Models\User;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
beforeEach(function () {
|
||||
$this->user = User::factory()->create();
|
||||
@@ -94,9 +97,11 @@ test('all boolean fields default correctly when not provided', function () {
|
||||
// Boolean fields can be null or false depending on database defaults
|
||||
expect($env->is_multiline)->toBeIn([false, null]);
|
||||
expect($env->is_preview)->toBeIn([false, null]);
|
||||
expect($env->is_runtime)->toBeIn([false, null]);
|
||||
expect($env->is_buildtime)->toBeIn([false, null]);
|
||||
expect($env->is_shown_once)->toBeIn([false, null]);
|
||||
|
||||
// New variables are available at runtime and build time unless configured otherwise
|
||||
expect($env->is_runtime)->toBeTrue();
|
||||
expect($env->is_buildtime)->toBeTrue();
|
||||
});
|
||||
|
||||
test('value field is properly encrypted when mass assigned', function () {
|
||||
@@ -113,7 +118,7 @@ test('value field is properly encrypted when mass assigned', function () {
|
||||
expect($env->value)->toBe($plainValue);
|
||||
|
||||
// Verify it's actually encrypted in the database
|
||||
$rawValue = \DB::table('environment_variables')
|
||||
$rawValue = DB::table('environment_variables')
|
||||
->where('id', $env->id)
|
||||
->value('value');
|
||||
|
||||
@@ -121,17 +126,26 @@ test('value field is properly encrypted when mass assigned', function () {
|
||||
expect($rawValue)->not->toBeNull();
|
||||
});
|
||||
|
||||
test('key field is trimmed and spaces replaced with underscores', function () {
|
||||
test('key field is trimmed', function () {
|
||||
$env = EnvironmentVariable::create([
|
||||
'key' => ' TEST KEY WITH SPACES ',
|
||||
'key' => ' TEST_KEY ',
|
||||
'value' => 'test_value',
|
||||
'resourceable_type' => Application::class,
|
||||
'resourceable_id' => $this->application->id,
|
||||
]);
|
||||
|
||||
expect($env->key)->toBe('TEST_KEY_WITH_SPACES');
|
||||
expect($env->key)->toBe('TEST_KEY');
|
||||
});
|
||||
|
||||
test('key field with inner spaces is rejected', function () {
|
||||
EnvironmentVariable::create([
|
||||
'key' => ' TEST KEY WITH SPACES ',
|
||||
'value' => 'test_value',
|
||||
'resourceable_type' => Application::class,
|
||||
'resourceable_id' => $this->application->id,
|
||||
]);
|
||||
})->throws(InvalidArgumentException::class);
|
||||
|
||||
test('version field can be mass assigned', function () {
|
||||
$env = EnvironmentVariable::create([
|
||||
'key' => 'TEST_VAR',
|
||||
@@ -164,7 +178,7 @@ test('mass assignment works with update method', function () {
|
||||
|
||||
expect($env->value)->toBe('updated_value');
|
||||
expect($env->comment)->toBe('Updated comment');
|
||||
expect($env->is_literal)->toBeTrue();
|
||||
expect((bool) $env->is_literal)->toBeTrue();
|
||||
});
|
||||
|
||||
test('protected attributes cannot be mass assigned', function () {
|
||||
|
||||
@@ -1,14 +1,9 @@
|
||||
<?php
|
||||
|
||||
it('uses Alpine entangle to switch add value field immediately when multiline is enabled', function () {
|
||||
it('uses distinct keys for the add value field modes', function () {
|
||||
$view = file_get_contents(resource_path('views/livewire/project/shared/environment-variable/add.blade.php'));
|
||||
|
||||
expect($view)
|
||||
->toContain('x-data="{ isMultiline: $wire.entangle(\'is_multiline\') }"')
|
||||
->toContain('<template x-if="isMultiline">')
|
||||
->toContain('<template x-if="!isMultiline">')
|
||||
->toContain('id="is_multiline"')
|
||||
->toContain('<x-forms.textarea id="value" label="Value" required class="font-sans" spellcheck />')
|
||||
->toContain('wire:key="env-value-textarea"')
|
||||
->toContain('wire:key="env-value-input"');
|
||||
});
|
||||
@@ -17,58 +12,6 @@ it('uses distinct keyed branches for the edit value field modes', function () {
|
||||
$view = file_get_contents(resource_path('views/livewire/project/shared/environment-variable/show.blade.php'));
|
||||
|
||||
expect($view)
|
||||
->toContain('wire:key="env-show-value-textarea-{{ $env->id }}"')
|
||||
->toContain('wire:key="env-show-value-input-{{ $env->id }}"');
|
||||
});
|
||||
|
||||
it('lazy-loads decrypted values only when opening the edit modal', function () {
|
||||
$view = file_get_contents(resource_path('views/livewire/project/shared/environment-variable/show.blade.php'));
|
||||
|
||||
expect($view)
|
||||
->toContain('wire:click="loadValues"')
|
||||
->toContain('wireOpen="editorOpen"')
|
||||
->toContain('$valuesLoaded')
|
||||
->toContain('Loading value...');
|
||||
|
||||
expect($view)->toContain('<x-forms.input loading loadingText="Loading value..."');
|
||||
});
|
||||
|
||||
it('keeps the environment variable delete button compact', function () {
|
||||
$view = file_get_contents(resource_path('views/livewire/project/shared/environment-variable/show.blade.php'));
|
||||
|
||||
expect($view)->not->toContain('buttonFullWidth="true"');
|
||||
});
|
||||
|
||||
it('uses sans font for the developer bulk environment variable editor', function () {
|
||||
$view = file_get_contents(resource_path('views/livewire/project/shared/environment-variable/all.blade.php'));
|
||||
|
||||
expect($view)
|
||||
->toContain('class="whitespace-pre-wrap font-sans"')
|
||||
->not->toContain('wire:model="variables" monospace')
|
||||
->not->toContain('wire:model="variablesPreview" monospace');
|
||||
});
|
||||
|
||||
it('loads environment variables asynchronously after first paint', function () {
|
||||
$view = file_get_contents(resource_path('views/livewire/project/shared/environment-variable/all.blade.php'));
|
||||
|
||||
expect($view)
|
||||
->toContain('wire:init="loadEnvironmentVariables"')
|
||||
->toContain('Loading environment variables...');
|
||||
});
|
||||
|
||||
it('renders a single no results message for empty environment variable searches', function () {
|
||||
$view = file_get_contents(resource_path('views/livewire/project/shared/environment-variable/all.blade.php'));
|
||||
|
||||
expect($view)
|
||||
->toContain('@if ($this->isSearchActive && $totalRows === 0)')
|
||||
->toContain('No environment variables found');
|
||||
});
|
||||
|
||||
it('paginates a unified environment variable table instead of loading every row', function () {
|
||||
$view = file_get_contents(resource_path('views/livewire/project/shared/environment-variable/all.blade.php'));
|
||||
|
||||
expect($view)
|
||||
->toContain('$this->environmentVariablePageRows')
|
||||
->toContain('nextEnvironmentVariablePage')
|
||||
->not->toContain('@forelse ($this->environmentVariables as $env)');
|
||||
->toContain('wire:key="env-show-value-multiline-{{ $env->id }}"')
|
||||
->toContain('wire:key="env-show-value-single-{{ $env->id }}"');
|
||||
});
|
||||
|
||||
@@ -1,126 +1,5 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* Resource environment variables table: full names, Managed as a column,
|
||||
* Type owns Production/Preview (no desktop Production badge in the name cell).
|
||||
*/
|
||||
test('resource environment variables table has a Managed column and no name-cell Production badge on desktop', function () {
|
||||
$all = file_get_contents(resource_path('views/livewire/project/shared/environment-variable/all.blade.php'));
|
||||
$show = file_get_contents(resource_path('views/livewire/project/shared/environment-variable/show.blade.php'));
|
||||
$hardcoded = file_get_contents(resource_path('views/livewire/project/shared/environment-variable/show-hardcoded.blade.php'));
|
||||
$css = file_get_contents(resource_path('css/app.css'));
|
||||
$filter = file_get_contents(resource_path('views/components/table/filter.blade.php'));
|
||||
$loading = file_get_contents(resource_path('views/components/table/loading.blade.php'));
|
||||
|
||||
// Header includes Managed between Name and Type.
|
||||
expect($all)
|
||||
->toContain('$showEnvironmentType = $showPreview')
|
||||
->toContain("toggleVariableFilter('{{ \$value }}')")
|
||||
->toContain('toggleServiceFilter(@js($serviceName))')
|
||||
->toContain('$this->serviceFilterOptions')
|
||||
->toContain('toggleVariableFilter,toggleServiceFilter,clearFilters,setEnvironmentFilter')
|
||||
->toContain('<x-table.loading target="clearFilters"')
|
||||
->toContain('reset-action="clearFilters"')
|
||||
->not->toContain("'all' => 'All variables'")
|
||||
->toContain("setTableSort('{{ \$value }}')")
|
||||
->toContain('Loading environment variables...')
|
||||
->toContain('opacity-40 blur-[2px]')
|
||||
->toContain('setEnvironmentVariablePage,previousEnvironmentVariablePage,nextEnvironmentVariablePage')
|
||||
->toContain("'buildtime' => 'Buildtime'")
|
||||
->toContain("'runtime' => 'Runtime'")
|
||||
->toContain("'multiline' => 'Multiline'")
|
||||
->toContain("'literal' => 'Literal'")
|
||||
->toContain('$activeFilterCount')
|
||||
->toContain('$activeFilterText')
|
||||
->toContain("'flex size-4 shrink-0 items-center justify-center rounded-[5px] border'")
|
||||
->toContain('m2.25 6.15 2.35 2.3 5.15-5')
|
||||
->toContain('>Name</span>')
|
||||
->toContain('>Managed</span>')
|
||||
->toContain('>Type</span>')
|
||||
->and($filter)
|
||||
->toContain('Reset filters')
|
||||
->toContain('max-h-80 overflow-y-auto p-1')
|
||||
->toContain('min-w-44! overflow-hidden! p-0!')
|
||||
->and($loading)
|
||||
->toContain('wire:loading.flex');
|
||||
|
||||
// Name cell does not repeat the environment type; Type owns Production/Preview.
|
||||
expect($show)
|
||||
->toContain('env-managed-desktop')
|
||||
->toContain('env-type-desktop')
|
||||
->not->toContain('env-type-mobile')
|
||||
->not->toContain('env-managed-mobile')
|
||||
->toContain('env-managed-desktop data-table-cell-check')
|
||||
->toContain('$isMagicVariable');
|
||||
|
||||
// Production/Managed desktop badges must not sit bare in the name cell without mobile class.
|
||||
expect($show)->not->toMatch(
|
||||
'/env-key-label[\s\S]{0,400}<span class="table-badge shrink-0">Managed<\/span>/'
|
||||
);
|
||||
expect($show)->not->toMatch(
|
||||
'/env-key-label[\s\S]{0,500}<span class="table-badge shrink-0">\{\{\s*\$rowScopeLabel\s*\}\}<\/span>/'
|
||||
);
|
||||
|
||||
expect($hardcoded)
|
||||
->toContain('env-managed-desktop data-table-cell-check')
|
||||
->toContain('title="Environment variable details"')
|
||||
->toContain('<x-forms.input label="Value" :value="$value ?? \'\'" readonly />')
|
||||
->not->toContain("{{ filled(\$value) ? \$value : '(empty)' }}")
|
||||
->toContain('env-type-desktop')
|
||||
->not->toContain('env-type-mobile')
|
||||
->not->toContain('env-managed-mobile');
|
||||
|
||||
// Mobile-only badge classes beat .table-badge display on desktop.
|
||||
expect($css)
|
||||
->toContain('.env-managed-desktop')
|
||||
// Resource grid is 9 columns (includes Managed).
|
||||
->toContain('minmax(14rem, 2.5fr) 4.8rem 6rem 4rem 4.5rem 4.8rem 4.2rem 3rem');
|
||||
|
||||
expect($all)->not->toContain('<span>Comment</span>');
|
||||
expect($show)->toContain('<x-helper :helper="e($comment)" />');
|
||||
});
|
||||
|
||||
test('resource environment variables expose their settings action without horizontal scrolling on mobile', function () {
|
||||
$view = file_get_contents(resource_path('views/livewire/project/shared/environment-variable/all.blade.php'));
|
||||
$show = file_get_contents(resource_path('views/livewire/project/shared/environment-variable/show.blade.php'));
|
||||
$hardcoded = file_get_contents(resource_path('views/livewire/project/shared/environment-variable/show-hardcoded.blade.php'));
|
||||
$css = file_get_contents(resource_path('css/app.css'));
|
||||
|
||||
expect($view)
|
||||
->toContain('environment-table-scroll')
|
||||
->and($show)
|
||||
->toContain('data-env-name-trigger')
|
||||
->toContain('data-env-settings-trigger')
|
||||
->and($hardcoded)
|
||||
->toContain('data-env-name-trigger')
|
||||
->toContain('data-env-settings-trigger')
|
||||
->and($css)
|
||||
->toContain(".environment-table-scroll {\n overflow-x: auto;")
|
||||
->toContain(".environment-table-scroll .env-table-grid {\n min-width: 53rem;")
|
||||
->toContain('.data-table-header.env-table-grid')
|
||||
->toContain('.data-table-row.env-table-grid > :last-child')
|
||||
->not->toContain(".env-type-desktop {\n display: none");
|
||||
});
|
||||
|
||||
test('shared environment variables table still omits Managed column', function () {
|
||||
$editor = file_get_contents(resource_path('views/components/shared-variables/editor.blade.php'));
|
||||
$show = file_get_contents(resource_path('views/livewire/project/shared/environment-variable/show.blade.php'));
|
||||
|
||||
expect($editor)
|
||||
->toContain('env-table-grid-shared')
|
||||
->not->toContain('>Managed</span>');
|
||||
|
||||
// Shared rows skip the Managed column cell.
|
||||
expect($show)->toContain('! $isSharedVariable');
|
||||
});
|
||||
|
||||
test('shared environment variables empty state has spacing around the card', function () {
|
||||
$editor = file_get_contents(resource_path('views/components/shared-variables/editor.blade.php'));
|
||||
|
||||
expect($editor)
|
||||
->toMatch('/<div class="p-3">\s*<x-empty title="No shared variables"/');
|
||||
});
|
||||
|
||||
test('managed environment variables are ordered first', function () {
|
||||
$component = file_get_contents(app_path('Livewire/Project/Shared/EnvironmentVariable/All.php'));
|
||||
|
||||
@@ -141,24 +20,3 @@ test('missing required environment variables are ordered before generated servic
|
||||
->and($component)
|
||||
->toContain('->filter(fn (EnvironmentVariable $environmentVariable): bool => $environmentVariable->is_really_required)');
|
||||
});
|
||||
|
||||
test('environment variable toolbar does not use blade directives inside component attributes', function () {
|
||||
$view = file_get_contents(resource_path('views/livewire/project/shared/environment-variable/all.blade.php'));
|
||||
|
||||
expect($view)
|
||||
->not->toMatch('/<x-table\.toolbar[^>]*@if/')
|
||||
->toContain('aria-busy="{{ ! $readyToLoad ? \'true\' : \'false\' }}"');
|
||||
});
|
||||
|
||||
test('environment variable editor places delete left and update actions right', function () {
|
||||
$view = file_get_contents(resource_path('views/livewire/project/shared/environment-variable/show.blade.php'));
|
||||
|
||||
expect($view)
|
||||
->toContain('class="flex flex-wrap items-center justify-between gap-2 border-t')
|
||||
->toMatch('/data-environment-variable-delete-action[\s\S]+buttonTitle="Delete"[\s\S]+data-environment-variable-update-actions[\s\S]+Lock[\s\S]+Update variable/')
|
||||
->toContain('@environment-variable-updated.window="if ($event.detail.envId === @js($env->id)) modalOpen = false"')
|
||||
->toContain('<fieldset disabled wire:dirty.attr.remove="disabled">')
|
||||
->toContain('type="submit" isHighlighted wire:target="submit"')
|
||||
->toContain('wire:dirty.attr.remove="disabled"')
|
||||
->not->toContain('@click="modalOpen = false"');
|
||||
});
|
||||
|
||||
@@ -2,33 +2,6 @@
|
||||
|
||||
use Illuminate\Support\Facades\Blade;
|
||||
|
||||
it('marks error buttons with the shared error class', function () {
|
||||
$html = Blade::render('<x-forms.button isError>Delete service</x-forms.button>');
|
||||
|
||||
expect($html)
|
||||
->toContain('class="button button-error"')
|
||||
->not->toContain('isError');
|
||||
});
|
||||
|
||||
it('keeps unavailable destructive actions styled as error buttons', function () {
|
||||
$view = file_get_contents(resource_path('views/livewire/project/shared/danger.blade.php'));
|
||||
|
||||
expect($view)->toContain('<x-forms.button isError disabled tooltip="You do not have permission to delete this resource.">');
|
||||
});
|
||||
|
||||
it('uses the shared error style for destructive form buttons', function (string $view, string $button) {
|
||||
expect(file_get_contents(resource_path("views/{$view}")))->toContain($button);
|
||||
})->with([
|
||||
'unavailable team deletion' => [
|
||||
'livewire/team/danger-zone.blade.php',
|
||||
'<x-forms.button isError disabled tooltip="Resolve the requirements shown before deleting this team.">',
|
||||
],
|
||||
'tag removal' => [
|
||||
'livewire/project/shared/tags.blade.php',
|
||||
'<x-forms.button isError wire:click="deleteTag',
|
||||
],
|
||||
]);
|
||||
|
||||
it('disables the button and targets the wire click action while loading', function () {
|
||||
$html = Blade::render(
|
||||
'<x-forms.button wire:click.prevent="checkConnection"><x-reicon name="refresh" class="size-3.5" />Check connection</x-forms.button>'
|
||||
@@ -57,12 +30,6 @@ it('uses an explicit wire target for submit buttons without wire click', functio
|
||||
->toContain('Validate and add');
|
||||
});
|
||||
|
||||
it('targets the S3 connection validation submit action', function () {
|
||||
$view = file_get_contents(resource_path('views/livewire/storage/create.blade.php'));
|
||||
|
||||
expect($view)->toContain('<x-forms.button class="mt-4" type="submit" wire:target="submit">');
|
||||
});
|
||||
|
||||
it('keeps method arguments when deriving the loading target', function () {
|
||||
$html = Blade::render(
|
||||
'<x-forms.button wire:click="setPrivateKey(42)">Use this key</x-forms.button>'
|
||||
@@ -85,18 +52,3 @@ it('skips loading attributes when the loading indicator is disabled', function (
|
||||
->not->toContain('animate-spin')
|
||||
->toContain('Check connection');
|
||||
});
|
||||
|
||||
it('hides static button icons with the shared is-loading utility', function () {
|
||||
$css = file_get_contents(resource_path('css/utilities.css'));
|
||||
|
||||
expect($css)->toContain('.button.is-loading > svg:not(.animate-spin)');
|
||||
});
|
||||
|
||||
it('applies automatic loading behavior on the server private key check connection button', function () {
|
||||
$view = file_get_contents(resource_path('views/livewire/server/private-key/show.blade.php'));
|
||||
|
||||
expect($view)
|
||||
->toContain('wire:click.prevent="checkConnection"')
|
||||
->toContain('<x-reicon name="refresh" class="size-3.5" />')
|
||||
->toContain('<x-forms.button canGate="update" :canResource="$server"');
|
||||
});
|
||||
|
||||
@@ -26,38 +26,6 @@ beforeEach(function () {
|
||||
]);
|
||||
});
|
||||
|
||||
it('places source navigation in the sidebar and source status in breadcrumbs', function () {
|
||||
$view = file_get_contents(resource_path('views/livewire/source/github/change.blade.php'));
|
||||
$navbar = file_get_contents(resource_path('views/components/dashboard/navbar.blade.php'));
|
||||
|
||||
expect($view)
|
||||
->toContain(':mobileTitleOnly="true"')
|
||||
->not->toContain('<x-slot:titleMeta>')
|
||||
->toContain("'label' => 'Permissions'")
|
||||
->toContain("'icon' => 'keys'")
|
||||
->toContain("'label' => 'Resources'")
|
||||
->toContain('application-settings-navigation min-w-0 xl:self-start')
|
||||
->not->toContain('application-settings-navigation min-w-0 xl:sticky')
|
||||
->toContain('source.github.danger')
|
||||
->toContain('Danger Zone')
|
||||
->toContain('github-app-danger-section')
|
||||
->toContain('submitAction="delete"');
|
||||
|
||||
expect($navbar)
|
||||
->toContain("request()->routeIs('source.github.show', 'source.github.permissions', 'source.github.resources', 'source.github.danger')")
|
||||
->not->toContain("['label' => 'Permissions', 'route' => 'source.github.permissions'")
|
||||
->not->toContain("['label' => 'Resources', 'route' => 'source.github.resources'");
|
||||
|
||||
expect(file_get_contents(resource_path('views/components/top-breadcrumb.blade.php')))
|
||||
->toContain('x-breadcrumb-switcher')
|
||||
->toContain('$currentSource->name')
|
||||
->toContain("route('source.github.show'")
|
||||
->toContain("route('source.gitlab.show'");
|
||||
|
||||
expect(file_get_contents(base_path('routes/web.php')))
|
||||
->toContain("->name('source.github.danger')");
|
||||
});
|
||||
|
||||
it('sets the danger active tab from the danger route', function () {
|
||||
$githubApp = GithubApp::create([
|
||||
'uuid' => (string) str()->uuid(),
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
describe('GitHub Manual Webhook', function () {
|
||||
test('ping event returns pong', function () {
|
||||
$response = $this->postJson('/webhooks/source/github/events/manual', [], [
|
||||
|
||||
@@ -46,12 +46,6 @@ beforeEach(function () {
|
||||
});
|
||||
|
||||
describe('GitLab App authorization', function () {
|
||||
test('empty gitlab app state is inset from the section edges', function () {
|
||||
$view = file_get_contents(resource_path('views/livewire/project/new/gitlab-private-repository.blade.php'));
|
||||
|
||||
expect($view)->toContain("<div class=\"application-settings-section-body\">\n <x-empty title=\"No GitLab Apps\"");
|
||||
});
|
||||
|
||||
test('unrelated users cannot inspect system-wide source secrets in the component payload', function () {
|
||||
$otherTeam = Team::factory()->create();
|
||||
$systemWideSource = GitlabApp::create([
|
||||
@@ -184,5 +178,4 @@ describe('GitLab App authorization', function () {
|
||||
expect(fn () => $this->owner->can('update', $orphaned))->not->toThrow(TypeError::class);
|
||||
expect($this->owner->can('update', $orphaned))->toBeFalse();
|
||||
});
|
||||
|
||||
});
|
||||
|
||||
@@ -13,11 +13,20 @@ afterEach(function () {
|
||||
Model::encryptUsing(null);
|
||||
});
|
||||
|
||||
/**
|
||||
* Http::GitSource() pins DNS before Http::fake() answers, so use a public IP
|
||||
* literal that needs no DNS lookup. No request leaves the test.
|
||||
*/
|
||||
function gitlabListingApiUrl(): string
|
||||
{
|
||||
return 'https://1.1.1.1/api/v4';
|
||||
}
|
||||
|
||||
it('returns a stable shape with has_more false when GitLab repo listing fails', function () {
|
||||
Http::fake(['*' => Http::response(['message' => '401 Unauthorized'], 401)]);
|
||||
|
||||
$source = new GitlabApp([
|
||||
'api_url' => 'https://gitlab.example.test/api/v4',
|
||||
'api_url' => gitlabListingApiUrl(),
|
||||
'access_token' => str_repeat('t', 20), // ggignore
|
||||
'refresh_token' => str_repeat('r', 20), // ggignore
|
||||
'expires_at' => time() + 3600,
|
||||
@@ -38,7 +47,7 @@ it('limits GitLab repositories to the exact group and its descendants', function
|
||||
], 200)]);
|
||||
|
||||
$source = new GitlabApp([
|
||||
'api_url' => 'https://gitlab.example.test/api/v4',
|
||||
'api_url' => gitlabListingApiUrl(),
|
||||
'access_token' => str_repeat('t', 20), // ggignore
|
||||
'refresh_token' => str_repeat('r', 20), // ggignore
|
||||
'expires_at' => time() + 3600,
|
||||
|
||||
@@ -38,16 +38,15 @@ beforeEach(function () {
|
||||
});
|
||||
|
||||
describe('GitLab source setup view', function () {
|
||||
test('shows red incomplete-setup alert and keeps advanced fields collapsed', function () {
|
||||
test('shows incomplete-setup guidance with the credential form', function () {
|
||||
Livewire::withQueryParams(['gitlab_app_uuid' => $this->gitlabApp->uuid])
|
||||
->test(Change::class)
|
||||
->assertSee('You must complete this step before you can use this source!')
|
||||
->assertSeeHtml('alert-error')
|
||||
->assertSee('Advanced / Self-hosted')
|
||||
->assertSee('Finish connecting this GitLab App before using it as a source')
|
||||
->assertSee('Advanced / self-hosted')
|
||||
->assertSee('Application ID')
|
||||
->assertSee('Application Secret')
|
||||
->assertSee('Application secret')
|
||||
->assertSee('Save')
|
||||
->assertDontSee('alert-warning');
|
||||
->assertDontSee('Test connection');
|
||||
});
|
||||
|
||||
test('derives api url when gitlab url changes', function () {
|
||||
@@ -66,14 +65,6 @@ describe('GitLab source setup view', function () {
|
||||
$this->assertModelMissing($this->gitlabApp);
|
||||
});
|
||||
|
||||
test('uses the persisted name for the title during a post-delete update', function () {
|
||||
$view = file_get_contents(resource_path('views/livewire/source/gitlab/change.blade.php'));
|
||||
|
||||
expect($view)
|
||||
->toContain("{{ \$name ?: 'GitLab App' }} | Sources | Coolify")
|
||||
->not->toContain("{{ \$gitlab_app->name ?: 'GitLab App' }} | Sources | Coolify");
|
||||
});
|
||||
|
||||
test('saves and reloads the application secret after refresh', function () {
|
||||
Livewire::withQueryParams(['gitlab_app_uuid' => $this->gitlabApp->uuid])
|
||||
->test(Change::class)
|
||||
@@ -94,7 +85,8 @@ describe('GitLab source setup view', function () {
|
||||
test('supports github-style custom public endpoint for oauth redirect uri', function () {
|
||||
Livewire::withQueryParams(['gitlab_app_uuid' => $this->gitlabApp->uuid])
|
||||
->test(Change::class)
|
||||
->assertSee('Use custom webhook endpoint')
|
||||
->assertSee('Webhook endpoint')
|
||||
->assertSee('Use a custom endpoint')
|
||||
->assertSee('Selected endpoint')
|
||||
->set('use_custom_webhook_endpoint', true)
|
||||
->set('custom_webhook_endpoint', 'http://100.75.155.70:8000')
|
||||
|
||||
@@ -21,7 +21,7 @@ beforeEach(function () {
|
||||
describe('GitLab source create modal', function () {
|
||||
test('matches github create modal structure', function () {
|
||||
Livewire::test(Create::class)
|
||||
->assertSee('This is required if you would like to get full integration')
|
||||
->assertSee('Connect a GitLab OAuth application for private repositories, webhooks, and deployments.')
|
||||
->assertSee('Self-hosted GitLab')
|
||||
->assertSee('Continue')
|
||||
->assertDontSee('>Save</', false)
|
||||
|
||||
@@ -2,22 +2,6 @@
|
||||
|
||||
use Illuminate\Support\Facades\Blade;
|
||||
|
||||
/**
|
||||
* Header version badge should open the matching GitHub release page.
|
||||
*/
|
||||
test('desktop header version links to the coolify github release for the installed version', function () {
|
||||
$layout = file_get_contents(resource_path('views/layouts/app.blade.php'));
|
||||
$version = file_get_contents(resource_path('views/components/version.blade.php'));
|
||||
|
||||
expect($layout)
|
||||
->toContain('<x-version')
|
||||
->not->toContain("class=\"text-[10.5px] font-medium text-neutral-400 dark:text-fg-faint\">v{{ config('constants.coolify.version') }}</span>");
|
||||
|
||||
expect($version)
|
||||
->toContain("https://github.com/coollabsio/coolify/releases/tag/v{{ config('constants.coolify.version') }}")
|
||||
->toContain('target="_blank"');
|
||||
});
|
||||
|
||||
test('development versions are not linked to nonexistent github releases', function () {
|
||||
config(['constants.coolify.version' => '4.3.1-dev.d64cbda3e']);
|
||||
|
||||
@@ -28,11 +12,3 @@ test('development versions are not linked to nonexistent github releases', funct
|
||||
->not->toContain('href=')
|
||||
->not->toContain('target="_blank"');
|
||||
});
|
||||
|
||||
test('mobile sidebar shows the installed coolify version when opened', function () {
|
||||
$layout = file_get_contents(resource_path('views/layouts/app.blade.php'));
|
||||
|
||||
expect($layout)
|
||||
->toContain('data-mobile-sidebar-brand')
|
||||
->toContain('<x-version class="!text-[10.5px]');
|
||||
});
|
||||
|
||||
@@ -2,149 +2,6 @@
|
||||
|
||||
use Illuminate\Support\Facades\Blade;
|
||||
|
||||
test('helper trigger is a button that stops label activation', function () {
|
||||
$path = resource_path('views/components/helper.blade.php');
|
||||
$contents = file_get_contents($path);
|
||||
|
||||
expect($contents)
|
||||
->toContain('type="button"')
|
||||
->toContain('@click.prevent.stop')
|
||||
->toContain('aria-label="{{ $label }}"')
|
||||
->toContain('data-icon-tooltip-ignore')
|
||||
->toContain('info-helper-popup')
|
||||
->toContain('name="info-circle"')
|
||||
->toContain('class="size-3.5 text-neutral-400')
|
||||
->not->toContain('<div x-ref="trigger" class="info-helper"');
|
||||
});
|
||||
|
||||
test('helper popup uses the redesigned raised surface styles', function () {
|
||||
$helper = file_get_contents(resource_path('views/components/helper.blade.php'));
|
||||
$utilities = file_get_contents(resource_path('css/utilities.css'));
|
||||
|
||||
expect($helper)
|
||||
->toContain('info-helper-popup')
|
||||
->toContain('x-transition:enter')
|
||||
->toContain('text-[13px] leading-5');
|
||||
|
||||
expect($utilities)
|
||||
->toContain('@utility info-helper-popup')
|
||||
->toContain('dark:bg-raised')
|
||||
->toContain('dark:border-white/10')
|
||||
->toContain('shadow-modal')
|
||||
->toContain('@utility auth-tooltip')
|
||||
->toContain('rounded-lg');
|
||||
});
|
||||
|
||||
test('helper popup remains open while moving from the trigger into interactive content', function () {
|
||||
$helper = file_get_contents(resource_path('views/components/helper.blade.php'));
|
||||
|
||||
expect($helper)
|
||||
->toContain('hideTimer: null')
|
||||
->toContain('setTimeout(() =>')
|
||||
->toContain('@mouseenter="cancelHide()"')
|
||||
->toContain('@mouseleave="hide()"');
|
||||
});
|
||||
|
||||
test('clicking a helper does not pin its popup open', function () {
|
||||
$helper = file_get_contents(resource_path('views/components/helper.blade.php'));
|
||||
|
||||
expect($helper)
|
||||
->toContain('@click.prevent.stop="show()"')
|
||||
->not->toContain('pinned');
|
||||
});
|
||||
|
||||
test('helper popup stays in its alpine scope during livewire morphs', function () {
|
||||
$helper = file_get_contents(resource_path('views/components/helper.blade.php'));
|
||||
|
||||
expect($helper)
|
||||
->not->toContain('x-teleport');
|
||||
});
|
||||
|
||||
test('helper triggers do not create stacking contexts above open tooltips', function () {
|
||||
$helper = file_get_contents(resource_path('views/components/helper.blade.php'));
|
||||
|
||||
expect($helper)
|
||||
->toContain("'class' => 'relative inline-block align-middle'")
|
||||
->toContain("'info-helper relative inline-flex")
|
||||
->not->toContain("'class' => 'relative z-10 inline-block align-middle'")
|
||||
->not->toContain("'info-helper relative z-10 inline-flex");
|
||||
});
|
||||
|
||||
test('helper popup prefers a position below its trigger and falls back above', function () {
|
||||
$helper = file_get_contents(resource_path('views/components/helper.blade.php'));
|
||||
|
||||
expect($helper)
|
||||
->toContain('let top = triggerRect.bottom + padding;')
|
||||
->toContain('let left = triggerRect.right - popupRect.width;')
|
||||
->toContain('top = triggerRect.top - popupRect.height - padding;');
|
||||
});
|
||||
|
||||
test('helper popup stays within the visual viewport on mobile', function () {
|
||||
$helper = file_get_contents(resource_path('views/components/helper.blade.php'));
|
||||
|
||||
expect($helper)
|
||||
->toContain('window.visualViewport')
|
||||
->toContain('viewport?.offsetLeft')
|
||||
->toContain('viewport?.offsetTop')
|
||||
->toContain('max-width: ${popupWidth}px;')
|
||||
->toContain('max-height: ${availableHeight}px;')
|
||||
->toContain('overflow-y: auto;');
|
||||
});
|
||||
|
||||
test('helper popup width is capped while allowing content to wrap vertically', function () {
|
||||
$helper = file_get_contents(resource_path('views/components/helper.blade.php'));
|
||||
|
||||
expect($helper)
|
||||
->toContain('const maxPopupWidth = 320;')
|
||||
->toContain('const popupWidth = Math.min(maxPopupWidth, availableWidth);')
|
||||
->toContain('max-width: ${popupWidth}px;')
|
||||
->toContain('whitespace-normal');
|
||||
});
|
||||
|
||||
test('helper popup supports focus dismissal and tooltip aria relationships', function () {
|
||||
$helper = file_get_contents(resource_path('views/components/helper.blade.php'));
|
||||
|
||||
expect($helper)
|
||||
->toContain('closeWhenFocusLeaves()')
|
||||
->toContain('@focus="show()"')
|
||||
->toContain('@blur="closeWhenFocusLeaves()"')
|
||||
->toContain('role="tooltip"')
|
||||
->toContain(':aria-describedby="open ? $id(\'helper-popup\') : null"')
|
||||
->toContain(':id="$id(\'helper-popup\')"');
|
||||
});
|
||||
|
||||
test('teleported helper popup closes from taps outside on mobile', function () {
|
||||
$helper = file_get_contents(resource_path('views/components/helper.blade.php'));
|
||||
|
||||
expect($helper)
|
||||
->toContain('@pointerdown.window="closeWhenPointerIsOutside($event)"')
|
||||
->toContain('closeWhenPointerIsOutside(event)')
|
||||
->toContain('this.$refs.popup?.contains(event.target)');
|
||||
});
|
||||
|
||||
test('button tooltips are available to keyboard and assistive technology users', function () {
|
||||
$button = file_get_contents(resource_path('views/components/forms/button.blade.php'));
|
||||
|
||||
expect($button)
|
||||
->toContain('@focusin="showTooltip()"')
|
||||
->toContain('@focusout="hideTooltip()"')
|
||||
->toContain('@click.outside="hideTooltip()"')
|
||||
->toContain('role="tooltip"')
|
||||
->toContain(':aria-describedby="visible ? $id(\'button-tooltip\') : null"')
|
||||
->toContain(':id="$id(\'button-tooltip\')"');
|
||||
});
|
||||
|
||||
test('listbox keeps the helper outside the label association', function () {
|
||||
$path = resource_path('views/components/forms/listbox.blade.php');
|
||||
$contents = file_get_contents($path);
|
||||
|
||||
expect($contents)
|
||||
->toContain('Keep helper outside the label')
|
||||
->toContain('<div class="mb-1.5 flex h-4 w-full items-center gap-1.5">')
|
||||
->toContain('class="mb-0! flex items-center gap-1.5 leading-4"')
|
||||
->toMatch('/<label for="\{\{ \$id \}\}-trigger"[^>]*>[\s\S]*?<\/label>\s*@if \(\$helper\)/');
|
||||
});
|
||||
|
||||
test('helper popup renders when the component is used next to a listbox label', function () {
|
||||
$html = Blade::render(<<<'BLADE'
|
||||
<x-forms.listbox id="serverTimezone" label="Server timezone"
|
||||
|
||||
@@ -2,8 +2,24 @@
|
||||
|
||||
use App\Models\GithubApp;
|
||||
|
||||
/**
|
||||
* The seeded "Public GitHub" source (id 0), built in memory so the test needs no database.
|
||||
*/
|
||||
function publicGithubAppForGitUrlTests(): GithubApp
|
||||
{
|
||||
return (new GithubApp)->forceFill([
|
||||
'id' => 0,
|
||||
'name' => 'Public GitHub',
|
||||
'api_url' => 'https://api.github.com',
|
||||
'html_url' => 'https://github.com',
|
||||
'custom_user' => 'git',
|
||||
'custom_port' => 22,
|
||||
'is_public' => true,
|
||||
]);
|
||||
}
|
||||
|
||||
test('convertGitUrlsForDeployKeyAndGithubAppAndHttpUrl', function () {
|
||||
$githubApp = GithubApp::find(0);
|
||||
$githubApp = publicGithubAppForGitUrlTests();
|
||||
$result = convertGitUrl('andrasbacsai/coolify-examples.git', 'deploy_key', $githubApp);
|
||||
expect($result)->toBe([
|
||||
'repository' => 'git@github.com:andrasbacsai/coolify-examples.git',
|
||||
@@ -13,7 +29,7 @@ test('convertGitUrlsForDeployKeyAndGithubAppAndHttpUrl', function () {
|
||||
});
|
||||
|
||||
test('convertGitUrlsForDeployKeyAndGithubAppAndSshUrl', function () {
|
||||
$githubApp = GithubApp::find(0);
|
||||
$githubApp = publicGithubAppForGitUrlTests();
|
||||
$result = convertGitUrl('git@github.com:andrasbacsai/coolify-examples.git', 'deploy_key', $githubApp);
|
||||
expect($result)->toBe([
|
||||
'repository' => 'git@github.com:andrasbacsai/coolify-examples.git',
|
||||
|
||||
@@ -35,7 +35,7 @@ it('does not enable Sentinel for excluded server types', function (array $settin
|
||||
|
||||
expect($server->fresh()->isSentinelEnabled())->toBeFalse();
|
||||
})->with([
|
||||
'build server' => [['is_build_server' => true]],
|
||||
'build server' => [['server_role' => 'build', 'is_build_server' => true]],
|
||||
'swarm manager' => [['is_swarm_manager' => true]],
|
||||
'swarm worker' => [['is_swarm_worker' => true]],
|
||||
'transferred server' => [[], ['transfer' => ['status' => 'transferred']]],
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
<?php
|
||||
|
||||
use App\Jobs\CheckTraefikVersionForServerJob;
|
||||
use App\Models\InstanceSettings;
|
||||
use App\Models\Server;
|
||||
use App\Models\Team;
|
||||
use App\Notifications\Server\TraefikVersionOutdated;
|
||||
@@ -10,11 +11,15 @@ use Illuminate\Support\Facades\Artisan;
|
||||
use Illuminate\Support\Facades\File;
|
||||
use Illuminate\Support\Facades\Notification;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
use Illuminate\Support\Once;
|
||||
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
beforeEach(function () {
|
||||
Notification::fake();
|
||||
InstanceSettings::unguarded(function () {
|
||||
InstanceSettings::updateOrCreate(['id' => 0], []);
|
||||
});
|
||||
});
|
||||
|
||||
it('detects servers table has detected_traefik_version column', function () {
|
||||
@@ -38,7 +43,8 @@ it('notification settings have traefik_outdated fields', function () {
|
||||
|
||||
// Check Telegram notification settings
|
||||
expect($team->telegramNotificationSettings)->toHaveKey('traefik_outdated_telegram_notifications');
|
||||
expect($team->telegramNotificationSettings)->toHaveKey('telegram_notifications_traefik_outdated_thread_id');
|
||||
// Thread ids default to null, so check the loaded columns instead of isset().
|
||||
expect($team->telegramNotificationSettings->getAttributes())->toHaveKey('telegram_notifications_traefik_outdated_thread_id');
|
||||
|
||||
// Check Slack notification settings
|
||||
expect($team->slackNotificationSettings)->toHaveKey('traefik_outdated_slack_notifications');
|
||||
@@ -349,6 +355,10 @@ it('notification transforms multiple servers with URLs correctly', function () {
|
||||
});
|
||||
|
||||
it('notification uses base_url helper not config app.url', function () {
|
||||
config(['app.url' => 'http://localhost']);
|
||||
InstanceSettings::query()->whereKey(0)->update(['fqdn' => 'https://coolify.example.com']);
|
||||
Once::flush();
|
||||
|
||||
$team = Team::factory()->create();
|
||||
$server = Server::factory()->create([
|
||||
'name' => 'Test Server',
|
||||
@@ -368,5 +378,6 @@ it('notification uses base_url helper not config app.url', function () {
|
||||
// Verify URL starts with base_url() not config('app.url')
|
||||
$generatedUrl = $mail->viewData['servers'][0]['url'];
|
||||
expect($generatedUrl)->toStartWith(base_url());
|
||||
expect($generatedUrl)->toBe('https://coolify.example.com/server/test-uuid/proxy');
|
||||
expect($generatedUrl)->not->toContain('localhost');
|
||||
});
|
||||
|
||||
@@ -29,7 +29,7 @@ it('handles Redis scan returning false gracefully', function () {
|
||||
// Run the command in dry-run mode with restart flag to trigger cleanupStuckJobs
|
||||
// Use skip-overlapping to avoid additional keys() calls
|
||||
$this->artisan(CleanupRedis::class, ['--dry-run' => true, '--restart' => true, '--skip-overlapping' => true])
|
||||
->expectsOutput('DRY RUN MODE - No data will be deleted')
|
||||
->expectsOutputToContain('Redis cleanup: would delete')
|
||||
->expectsOutputToContain('Redis scan failed, stopping key retrieval')
|
||||
->assertSuccessful();
|
||||
});
|
||||
@@ -76,7 +76,7 @@ it('successfully scans Redis keys when scan returns valid results', function ()
|
||||
|
||||
// Run the command with restart flag to trigger cleanupStuckJobs
|
||||
$this->artisan(CleanupRedis::class, ['--dry-run' => true, '--restart' => true, '--skip-overlapping' => true])
|
||||
->expectsOutput('DRY RUN MODE - No data will be deleted')
|
||||
->expectsOutputToContain('Redis cleanup: would delete')
|
||||
->assertSuccessful();
|
||||
});
|
||||
|
||||
@@ -101,7 +101,7 @@ it('handles empty scan results gracefully', function () {
|
||||
|
||||
// Run the command with restart flag
|
||||
$this->artisan(CleanupRedis::class, ['--dry-run' => true, '--restart' => true, '--skip-overlapping' => true])
|
||||
->expectsOutput('DRY RUN MODE - No data will be deleted')
|
||||
->expectsOutputToContain('Redis cleanup: would delete')
|
||||
->assertSuccessful();
|
||||
});
|
||||
|
||||
|
||||
@@ -3,44 +3,45 @@
|
||||
use App\Jobs\ScheduledJobManager;
|
||||
use Illuminate\Support\Facades\Redis;
|
||||
|
||||
function scheduledJobManagerLockKey(): string
|
||||
{
|
||||
return config('cache.prefix').'laravel-queue-overlap:'.ScheduledJobManager::class.':scheduled-job-manager';
|
||||
}
|
||||
|
||||
it('clears stale lock when TTL is -1', function () {
|
||||
$cachePrefix = config('cache.prefix');
|
||||
$lockKey = $cachePrefix.'laravel-queue-overlap:'.ScheduledJobManager::class.':scheduled-job-manager';
|
||||
$lockKey = scheduledJobManagerLockKey();
|
||||
|
||||
$redis = Redis::connection('default');
|
||||
$redis->set($lockKey, 'stale-owner');
|
||||
$redis = Mockery::mock();
|
||||
$redis->shouldReceive('ttl')->once()->with($lockKey)->andReturn(-1);
|
||||
$redis->shouldReceive('del')->once()->with($lockKey)->andReturn(1);
|
||||
|
||||
expect($redis->ttl($lockKey))->toBe(-1);
|
||||
Redis::shouldReceive('connection')->with('default')->andReturn($redis);
|
||||
|
||||
$job = new ScheduledJobManager;
|
||||
$job->middleware();
|
||||
|
||||
expect($redis->exists($lockKey))->toBe(0);
|
||||
});
|
||||
|
||||
it('preserves valid lock with positive TTL', function () {
|
||||
$cachePrefix = config('cache.prefix');
|
||||
$lockKey = $cachePrefix.'laravel-queue-overlap:'.ScheduledJobManager::class.':scheduled-job-manager';
|
||||
$lockKey = scheduledJobManagerLockKey();
|
||||
|
||||
$redis = Redis::connection('default');
|
||||
$redis->set($lockKey, 'active-owner');
|
||||
$redis->expire($lockKey, 60);
|
||||
$redis = Mockery::mock();
|
||||
$redis->shouldReceive('ttl')->once()->with($lockKey)->andReturn(60);
|
||||
$redis->shouldNotReceive('del');
|
||||
|
||||
expect($redis->ttl($lockKey))->toBeGreaterThan(0);
|
||||
Redis::shouldReceive('connection')->with('default')->andReturn($redis);
|
||||
|
||||
$job = new ScheduledJobManager;
|
||||
$job->middleware();
|
||||
|
||||
expect($redis->exists($lockKey))->toBe(1);
|
||||
|
||||
$redis->del($lockKey);
|
||||
});
|
||||
|
||||
it('does not fail when no lock exists', function () {
|
||||
$cachePrefix = config('cache.prefix');
|
||||
$lockKey = $cachePrefix.'laravel-queue-overlap:'.ScheduledJobManager::class.':scheduled-job-manager';
|
||||
$lockKey = scheduledJobManagerLockKey();
|
||||
|
||||
Redis::connection('default')->del($lockKey);
|
||||
$redis = Mockery::mock();
|
||||
$redis->shouldReceive('ttl')->once()->with($lockKey)->andReturn(-2);
|
||||
$redis->shouldNotReceive('del');
|
||||
|
||||
Redis::shouldReceive('connection')->with('default')->andReturn($redis);
|
||||
|
||||
$job = new ScheduledJobManager;
|
||||
$middleware = $job->middleware();
|
||||
|
||||
@@ -1,15 +1,26 @@
|
||||
<?php
|
||||
|
||||
use App\Models\Environment;
|
||||
use App\Models\InstanceSettings;
|
||||
use App\Models\Project;
|
||||
use App\Models\ScheduledDatabaseBackup;
|
||||
use App\Models\ScheduledDatabaseBackupExecution;
|
||||
use App\Models\ScheduledTask;
|
||||
use App\Models\ScheduledTaskExecution;
|
||||
use App\Models\Server;
|
||||
use App\Models\StandaloneDocker;
|
||||
use App\Models\StandalonePostgresql;
|
||||
use App\Models\Team;
|
||||
use Carbon\Carbon;
|
||||
use Illuminate\Console\Command;
|
||||
use Illuminate\Contracts\Console\Kernel;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Support\Facades\Artisan;
|
||||
use Illuminate\Support\Facades\Http;
|
||||
use Illuminate\Support\Facades\Notification;
|
||||
use Illuminate\Support\Facades\Process;
|
||||
use Illuminate\Support\Facades\Queue;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
@@ -19,6 +30,30 @@ beforeEach(function () {
|
||||
|
||||
// Fake notifications to ensure none are sent
|
||||
Notification::fake();
|
||||
Http::fake();
|
||||
Process::fake();
|
||||
Queue::fake();
|
||||
|
||||
InstanceSettings::forceCreate(['id' => 0, 'do_not_track' => true]);
|
||||
|
||||
// app:init runs `optimize`, which boots a fresh application with a new in-memory
|
||||
// database and caches config into bootstrap/cache. Replace both with no-ops here.
|
||||
foreach (['optimize', 'optimize:clear'] as $commandName) {
|
||||
app(Kernel::class)->registerCommand(new class($commandName) extends Command
|
||||
{
|
||||
public function __construct(string $commandName)
|
||||
{
|
||||
$this->signature = $commandName;
|
||||
|
||||
parent::__construct();
|
||||
}
|
||||
|
||||
public function handle(): int
|
||||
{
|
||||
return self::SUCCESS;
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
afterEach(function () {
|
||||
@@ -86,15 +121,30 @@ test('app:init marks stuck database backup executions as failed', function () {
|
||||
$team = Team::factory()->create();
|
||||
|
||||
// Create a database
|
||||
$database = StandalonePostgresql::factory()->create([
|
||||
'team_id' => $team->id,
|
||||
$server = Server::factory()->create(['team_id' => $team->id]);
|
||||
$destination = StandaloneDocker::firstOrCreate(
|
||||
['server_id' => $server->id, 'network' => 'coolify'],
|
||||
['uuid' => (string) Str::uuid(), 'name' => 'docker']
|
||||
);
|
||||
$project = Project::factory()->create(['team_id' => $team->id]);
|
||||
$environment = Environment::factory()->create(['project_id' => $project->id]);
|
||||
$database = StandalonePostgresql::create([
|
||||
'name' => 'db',
|
||||
'postgres_user' => 'postgres',
|
||||
'postgres_password' => 'password',
|
||||
'postgres_db' => 'db',
|
||||
'image' => 'postgres:17',
|
||||
'environment_id' => $environment->id,
|
||||
'destination_id' => $destination->id,
|
||||
'destination_type' => $destination->getMorphClass(),
|
||||
]);
|
||||
|
||||
// Create a scheduled backup
|
||||
$scheduledBackup = ScheduledDatabaseBackup::factory()->create([
|
||||
$scheduledBackup = ScheduledDatabaseBackup::create([
|
||||
'frequency' => '0 0 * * *',
|
||||
'team_id' => $team->id,
|
||||
'database_id' => $database->id,
|
||||
'database_type' => StandalonePostgresql::class,
|
||||
'database_type' => $database->getMorphClass(),
|
||||
]);
|
||||
|
||||
// Create multiple backup executions with 'running' status
|
||||
@@ -185,7 +235,8 @@ test('app:init handles cleanup when no stuck executions exist', function () {
|
||||
|
||||
test('cleanup does not send notifications even when team has notification settings', function () {
|
||||
// Create a team with notification settings enabled
|
||||
$team = Team::factory()->create([
|
||||
$team = Team::factory()->create();
|
||||
$team->emailNotificationSettings->update([
|
||||
'smtp_enabled' => true,
|
||||
'smtp_from_address' => 'test@example.com',
|
||||
]);
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
<?php
|
||||
|
||||
test('sponsorship reminder listener is not parsed as a blade directive', function () {
|
||||
$view = file_get_contents(resource_path('views/livewire/layout-popups.blade.php'));
|
||||
$compiledView = Blade::compileString($view);
|
||||
|
||||
expect($compiledView)
|
||||
->toContain('x-on:show-sponsorship-reminder.window=')
|
||||
->not->toContain('$__env->yieldSection()');
|
||||
});
|
||||
@@ -77,39 +77,6 @@ it('renders the changed configuration labels without a second backend request',
|
||||
->assertSee('The latest configuration has not been applied')
|
||||
->assertSee('Rebuild required.')
|
||||
->assertSee('Build command');
|
||||
|
||||
$view = file_get_contents(resource_path('views/livewire/project/shared/configuration-checker.blade.php'));
|
||||
|
||||
expect($view)
|
||||
->toContain(':compact-after="5000"')
|
||||
->toContain('position="top-right"')
|
||||
->toContain(':compact-storage-key="$compactStorageKey"')
|
||||
->toContain('wire:key="configuration-warning-{{ $currentConfigurationHash }}"')
|
||||
->toContain('x-on:click="configurationDiffModalOpen = true"')
|
||||
->not->toContain('$wire.refreshConfigurationChanges()');
|
||||
});
|
||||
|
||||
it('supports timed compact popup notifications', function () {
|
||||
$view = file_get_contents(resource_path('views/components/popup-small.blade.php'));
|
||||
|
||||
expect($view)
|
||||
->toContain("\$position === 'top-right' ? 'top-16' : 'bottom-4'")
|
||||
->toContain('compactAfter')
|
||||
->toContain('compactStorageKey')
|
||||
->toContain("localStorage.setItem(this.storageKey, 'compact')")
|
||||
->toContain("localStorage.setItem(this.storageKey, 'icon')")
|
||||
->toContain('localStorage.removeItem(key)')
|
||||
->not->toContain('<template x-teleport="body">')
|
||||
->toContain('compact = true')
|
||||
->toContain('@click="restore()"')
|
||||
->toContain('@click.stop="minimizeToIcon()"')
|
||||
->toContain('<template x-if="iconOnly">')
|
||||
->toContain('<template x-if="!iconOnly">')
|
||||
->not->toContain('<button x-show="iconOnly"')
|
||||
->not->toContain('<div x-show="!iconOnly"')
|
||||
->not->toContain(':class="iconOnly')
|
||||
->toContain('x-show="!compact"')
|
||||
->toContain("'w-[calc(100vw-2rem)] max-w-sm cursor-pointer'");
|
||||
});
|
||||
|
||||
it('warns when a service has missing required environment variables', function () {
|
||||
@@ -161,18 +128,6 @@ it('refreshes the service configuration when a websocket configuration event arr
|
||||
->toHaveKey('configurationChanged', 'refreshServices');
|
||||
});
|
||||
|
||||
it('marks the service environment variables menu when required values are missing', function () {
|
||||
$configuration = file_get_contents(resource_path('views/livewire/project/service/configuration.blade.php'));
|
||||
$sidebar = file_get_contents(resource_path('views/components/service/configuration-sidebar.blade.php'));
|
||||
|
||||
expect($configuration)
|
||||
->toContain("'hasWarning' => ! \$service->isDeployable")
|
||||
->toContain('title="Required environment variables missing"')
|
||||
->and($sidebar)
|
||||
->toContain("'hasWarning' => ! \$service->isDeployable")
|
||||
->toContain('title="Required environment variables missing"');
|
||||
});
|
||||
|
||||
it('refreshes configuration changes when the event is received', function () {
|
||||
$application = configurationCheckerApplication($this->environment);
|
||||
markConfigurationCheckerApplicationDeployed($application);
|
||||
|
||||
@@ -46,11 +46,15 @@ it('shows Sentinel status independently of optional metrics', function (bool $me
|
||||
->assertSeeLivewire(GetLogs::class);
|
||||
})->with([false, true])->with([false, true]);
|
||||
|
||||
it('does not offer Sentinel controls or logs on unsupported servers', function (string $setting) {
|
||||
$this->server->settings()->update([$setting => true]);
|
||||
it('does not offer Sentinel controls or logs on unsupported servers', function (array $settings) {
|
||||
$this->server->settings()->update($settings);
|
||||
|
||||
Livewire::withQueryParams(['server_uuid' => $this->server->uuid])
|
||||
->test(Logs::class)
|
||||
->assertDontSee('Enable Sentinel')
|
||||
->assertDontSeeLivewire(GetLogs::class);
|
||||
})->with(['is_build_server', 'is_swarm_manager', 'is_swarm_worker']);
|
||||
})->with([
|
||||
'build server' => [['server_role' => 'build', 'is_build_server' => true]],
|
||||
'swarm manager' => [['is_swarm_manager' => true]],
|
||||
'swarm worker' => [['is_swarm_worker' => true]],
|
||||
]);
|
||||
|
||||
@@ -78,13 +78,22 @@ it('does not let team members restore Sentinel defaults', function () {
|
||||
$member = User::factory()->create();
|
||||
$this->server->team->members()->attach($member->id, ['role' => 'member']);
|
||||
$before = $this->server->settings->fresh()->getAttributes();
|
||||
Queue::fake();
|
||||
|
||||
// The action itself re-checks permission, even on a component that an owner opened.
|
||||
$component = Livewire::test(Sentinel::class, ['server' => $this->server]);
|
||||
$this->actingAs($member);
|
||||
session(['currentTeam' => $this->server->team]);
|
||||
$component->call('restoreDefaultConfiguration');
|
||||
|
||||
Queue::assertNothingPushed();
|
||||
|
||||
// Members cannot open the Sentinel settings at all, so the restore action is never reachable.
|
||||
Livewire::test(Sentinel::class, ['server' => $this->server])
|
||||
->call('restoreDefaultConfiguration');
|
||||
->assertForbidden();
|
||||
|
||||
expect($this->server->settings->fresh()->getAttributes())->toBe($before);
|
||||
expect($member->can('manageSentinel', $this->server))->toBeFalse()
|
||||
->and($this->server->settings->fresh()->getAttributes())->toBe($before);
|
||||
});
|
||||
|
||||
it('shows local troubleshooting guidance when Sentinel is out of sync', function () {
|
||||
|
||||
@@ -35,13 +35,3 @@ it('rejects statuses outside the supported list', function () {
|
||||
->call('fail', 500)
|
||||
->assertStatus(404);
|
||||
});
|
||||
|
||||
it('keeps the preview statuses in sync with the JS handler', function () {
|
||||
$source = file_get_contents(resource_path('js/livewire-request-failure.js'));
|
||||
|
||||
expect(preg_match('/INFRASTRUCTURE_FAILURE_STATUSES = new Set\(\[([\d,\s]+)\]\)/', $source, $matches))->toBe(1);
|
||||
|
||||
$jsStatuses = array_map('intval', array_map('trim', explode(',', $matches[1])));
|
||||
|
||||
expect((new LivewireRequestFailurePreview)->statuses)->toBe($jsStatuses);
|
||||
});
|
||||
|
||||
@@ -50,9 +50,14 @@ describe('mass assignment protection', function () {
|
||||
}
|
||||
});
|
||||
|
||||
test('Application model blocks mass assignment of relationship IDs', function () {
|
||||
/**
|
||||
* Relationship keys and uuid are fillable on purpose for internal create and clone
|
||||
* flows (see tests/Unit/ModelFillableRegressionTest.php). API controllers reject
|
||||
* them through their allowed-field lists, so only the primary key stays guarded.
|
||||
*/
|
||||
test('Application model blocks mass assignment of its primary key', function () {
|
||||
$application = new Application;
|
||||
$dangerousFields = ['id', 'uuid', 'environment_id', 'destination_id', 'destination_type', 'source_id', 'source_type', 'private_key_id', 'repository_project_id'];
|
||||
$dangerousFields = ['id', 'team_id'];
|
||||
|
||||
foreach ($dangerousFields as $field) {
|
||||
expect($application->isFillable($field))
|
||||
@@ -98,9 +103,6 @@ describe('mass assignment protection', function () {
|
||||
expect($user->isFillable('remember_token'))->toBeFalse('remember_token should not be fillable');
|
||||
expect($user->isFillable('two_factor_secret'))->toBeFalse('two_factor_secret should not be fillable');
|
||||
expect($user->isFillable('two_factor_recovery_codes'))->toBeFalse('two_factor_recovery_codes should not be fillable');
|
||||
expect($user->isFillable('pending_email'))->toBeFalse('pending_email should not be fillable');
|
||||
expect($user->isFillable('email_change_code'))->toBeFalse('email_change_code should not be fillable');
|
||||
expect($user->isFillable('email_change_code_expires_at'))->toBeFalse('email_change_code_expires_at should not be fillable');
|
||||
});
|
||||
|
||||
test('User model allows mass assignment of profile fields', function () {
|
||||
@@ -129,7 +131,7 @@ describe('mass assignment protection', function () {
|
||||
expect($team->isFillable('custom_server_limit'))->toBeTrue();
|
||||
});
|
||||
|
||||
test('standalone database models block mass assignment of relationship IDs', function () {
|
||||
test('standalone database models block mass assignment of their primary key', function () {
|
||||
$models = [
|
||||
StandalonePostgresql::class,
|
||||
StandaloneRedis::class,
|
||||
@@ -144,7 +146,7 @@ describe('mass assignment protection', function () {
|
||||
|
||||
foreach ($models as $modelClass) {
|
||||
$model = new $modelClass;
|
||||
$dangerousFields = ['id', 'uuid', 'environment_id', 'destination_id', 'destination_type'];
|
||||
$dangerousFields = ['id'];
|
||||
|
||||
foreach ($dangerousFields as $field) {
|
||||
expect($model->isFillable($field))
|
||||
@@ -226,26 +228,19 @@ describe('mass assignment protection', function () {
|
||||
test('Application fill ignores non-fillable fields', function () {
|
||||
$application = new Application;
|
||||
$application->fill([
|
||||
'id' => 999,
|
||||
'name' => 'test-app',
|
||||
'environment_id' => 999,
|
||||
'destination_id' => 999,
|
||||
'team_id' => 999,
|
||||
'private_key_id' => 999,
|
||||
]);
|
||||
|
||||
expect($application->name)->toBe('test-app');
|
||||
expect($application->environment_id)->toBeNull();
|
||||
expect($application->destination_id)->toBeNull();
|
||||
expect($application->private_key_id)->toBeNull();
|
||||
expect($application->id)->toBeNull();
|
||||
expect($application->team_id)->toBeNull();
|
||||
});
|
||||
|
||||
test('Service model blocks mass assignment of relationship IDs', function () {
|
||||
test('Service model blocks mass assignment of its primary key', function () {
|
||||
$service = new Service;
|
||||
|
||||
expect($service->isFillable('id'))->toBeFalse();
|
||||
expect($service->isFillable('uuid'))->toBeFalse();
|
||||
expect($service->isFillable('environment_id'))->toBeFalse();
|
||||
expect($service->isFillable('destination_id'))->toBeFalse();
|
||||
expect($service->isFillable('server_id'))->toBeFalse();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -4,6 +4,7 @@ use App\Actions\Application\StopApplication;
|
||||
use App\Actions\Database\StopDatabase;
|
||||
use App\Actions\Service\StopService;
|
||||
use App\Actions\Shared\MigrateResourceToDestination;
|
||||
use App\Enums\ServerRole;
|
||||
use App\Jobs\FinalizeResourceMigrationJob;
|
||||
use App\Jobs\HostPathCloneJob;
|
||||
use App\Jobs\VolumeCloneJob;
|
||||
@@ -110,6 +111,7 @@ test('rejects migration to a build server destination', function () {
|
||||
'is_reachable' => true,
|
||||
'is_usable' => true,
|
||||
'is_build_server' => true,
|
||||
'server_role' => ServerRole::BUILD,
|
||||
]);
|
||||
$buildDestination = StandaloneDocker::where('server_id', $buildServer->id)->firstOrFail();
|
||||
$application = createMigrateTestApplication($this);
|
||||
@@ -320,38 +322,6 @@ test('rejects migration to a server that is not validated and reachable', functi
|
||||
MigrateResourceToDestination::run($application, $this->targetDestination, migrateVolumes: false);
|
||||
})->throws(ValidationException::class);
|
||||
|
||||
test('resource operations migrate list only includes other functional servers', function () {
|
||||
$unreachableServer = Server::factory()->create([
|
||||
'team_id' => $this->team->id,
|
||||
'name' => 'Unreachable Server',
|
||||
]);
|
||||
$unreachableServer->settings()->update([
|
||||
'is_reachable' => false,
|
||||
'is_usable' => false,
|
||||
]);
|
||||
|
||||
$application = createMigrateTestApplication($this);
|
||||
$application->load(['destination.server', 'environment.project']);
|
||||
|
||||
$this->actingAs($this->user);
|
||||
session(['currentTeam' => $this->team]);
|
||||
|
||||
$component = Livewire::test(ResourceOperations::class, ['resource' => $application]);
|
||||
|
||||
$servers = $component->get('servers');
|
||||
$serverIds = collect($servers)->pluck('id')->all();
|
||||
|
||||
expect($serverIds)->toContain($this->server->id)
|
||||
->and($serverIds)->toContain($this->targetServer->id)
|
||||
->and($serverIds)->toContain($unreachableServer->id);
|
||||
|
||||
$view = file_get_contents(resource_path('views/livewire/project/shared/resource-operations.blade.php'));
|
||||
|
||||
expect($view)
|
||||
->toContain('server.is_functional && server.id != this.currentServerId')
|
||||
->toContain("'is_functional' => \$server->isFunctional()");
|
||||
});
|
||||
|
||||
test('migration is unavailable outside development mode', function () {
|
||||
config(['app.env' => 'production']);
|
||||
StopApplication::shouldNotRun();
|
||||
|
||||
@@ -2,20 +2,6 @@
|
||||
|
||||
use Illuminate\Support\Facades\Blade;
|
||||
|
||||
/**
|
||||
* Step 1 (checkbox options) should only show Continue — dismiss is the header X.
|
||||
*/
|
||||
test('modal confirmation step 1 does not render a cancel button', function () {
|
||||
$modal = file_get_contents(resource_path('views/components/modal-confirmation.blade.php'));
|
||||
|
||||
$step1 = Str::after($modal, '<!-- Step 1: Select actions -->');
|
||||
$step1 = Str::before($step1, '<!-- Step 2: Confirm deletion -->');
|
||||
|
||||
expect($step1)
|
||||
->toContain('step1ButtonText')
|
||||
->not->toContain('Cancel');
|
||||
});
|
||||
|
||||
test('modal confirmation lets checkbox ids define their Livewire model binding', function () {
|
||||
$checkbox = Blade::render(<<<'BLADE'
|
||||
<x-forms.checkbox id="deleteVolumes"
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user