Merge origin/master and resolve release timeline spec conflict

This commit is contained in:
copilot-swe-agent[bot]
2026-06-01 19:21:12 +00:00
committed by GitHub
18 changed files with 501 additions and 82 deletions
+2 -1
View File
@@ -23,6 +23,7 @@ jobs:
PLAYWRIGHT_RELEASE_API_BASE_URL: https://api-v2.truckwash.io
PLAYWRIGHT_RELEASE_API_PING_PATHS: /ping,/master/api/ping,/canary/api/ping,/stable/api/ping
RELEASE_BUILD_ID: ${{ github.run_id }}-${{ github.run_attempt }}
RELEASE_EXPECTED_BUILD_ID: ${{ github.run_id }}-${{ github.run_attempt }}
RELEASE_EXPECTED_COMMIT: ${{ github.sha }}
RELEASE_WAIT_INITIAL_SECONDS: 45
RELEASE_WAIT_TIMEOUT_SECONDS: 600
@@ -98,7 +99,7 @@ jobs:
-X POST "$RELEASE_MANAGER_GATE_URL" \
-H "Authorization: Bearer $RELEASE_MANAGER_GATE_TOKEN" \
-H "Content-Type: application/json" \
--data "{\"environment_url\":\"$RELEASE_BASE_URL\",\"channel_slug\":\"stable\",\"app\":\"frontend\",\"repository\":\"$RELEASE_REPOSITORY\",\"branch\":\"$RELEASE_BRANCH\",\"expected_commit\":\"$RELEASE_EXPECTED_COMMIT\",\"workflow_url\":\"$RELEASE_WORKFLOW_URL\",\"auto_sync\":true,\"wait_timeout_seconds\":300,\"poll_interval_seconds\":10,\"required_checks\":[\"static_artifact\",\"api_gateway\"]}"
--data "{\"environment_url\":\"$RELEASE_BASE_URL\",\"channel_slug\":\"stable\",\"app\":\"frontend\",\"repository\":\"$RELEASE_REPOSITORY\",\"branch\":\"$RELEASE_BRANCH\",\"expected_commit\":\"$RELEASE_EXPECTED_COMMIT\",\"build_id\":\"$RELEASE_EXPECTED_BUILD_ID\",\"workflow_url\":\"$RELEASE_WORKFLOW_URL\",\"auto_sync\":true,\"wait_timeout_seconds\":300,\"poll_interval_seconds\":10,\"required_checks\":[\"static_artifact\",\"api_gateway\"]}"
env:
RELEASE_MANAGER_GATE_URL: ${{ secrets.RELEASE_MANAGER_GATE_URL || 'https://api.truckwash.io/release/gate/test-runs' }}
RELEASE_MANAGER_GATE_TOKEN: ${{ secrets.RELEASE_MANAGER_GATE_TOKEN }}
+8
View File
@@ -38,6 +38,14 @@ To use another remote API route:
$env:VITE_API_PROXY_BASE_PATH="/canary/api"; npm run dev
```
TLS certificate validation is enabled for proxied HTTPS APIs by default. If you
are using a trusted local HTTPS API with a self-signed certificate, you can opt
out explicitly:
```powershell
$env:VITE_API_PROXY_TARGET="https://local-api.test"; $env:VITE_API_PROXY_SECURE="false"; npm run dev
```
For compatible local gateways that expect the `/api` prefix to be preserved:
```powershell
+13
View File
@@ -5,18 +5,27 @@ server {
root /usr/share/nginx/html;
index index.html;
add_header Content-Security-Policy "frame-ancestors 'self'" always;
add_header X-Frame-Options "SAMEORIGIN" always;
location ~ ^/(release-entry|release-manifest)\.json$ {
add_header Cache-Control "no-store";
add_header Content-Security-Policy "frame-ancestors 'self'" always;
add_header X-Frame-Options "SAMEORIGIN" always;
try_files $uri =404;
}
location ~ ^/(master|beta|canary|internal)/frontend/(release-entry|release-manifest)\.json$ {
add_header Cache-Control "no-store";
add_header Content-Security-Policy "frame-ancestors 'self'" always;
add_header X-Frame-Options "SAMEORIGIN" always;
try_files /$2.json =404;
}
location ~ ^/(?:.+/)?(?<static_asset_path>(?:assets|resources|favicons|icons|img|sounds|\.well-known)/.+)$ {
add_header Cache-Control "public, max-age=31536000, immutable";
add_header Content-Security-Policy "frame-ancestors 'self'" always;
add_header X-Frame-Options "SAMEORIGIN" always;
try_files /$static_asset_path =404;
}
@@ -30,11 +39,15 @@ server {
location /assets/ {
add_header Cache-Control "public, max-age=31536000, immutable";
add_header Content-Security-Policy "frame-ancestors 'self'" always;
add_header X-Frame-Options "SAMEORIGIN" always;
try_files $uri =404;
}
location ~ ^/(master|beta|canary|internal)/frontend/assets/ {
add_header Cache-Control "public, max-age=31536000, immutable";
add_header Content-Security-Policy "frame-ancestors 'self'" always;
add_header X-Frame-Options "SAMEORIGIN" always;
rewrite ^/(master|beta|canary|internal)/frontend/(.*)$ /$2 break;
try_files $uri =404;
}
+35 -3
View File
@@ -23,20 +23,52 @@ if [[ -z "$DEPLOY_URL" ]]; then
echo "Set RELEASE_DEPLOY_URL or RELEASE_DEPLOY_HOST, RELEASE_DEPLOY_USER, and RELEASE_DEPLOY_PASSWORD." >&2
exit 1
fi
DEPLOY_URL="ftp://${USER_NAME}:${PASSWORD}@${HOST}"
DEPLOY_URL="ftp://${HOST}"
elif [[ -n "$USER_NAME" || -n "$PASSWORD" ]]; then
if [[ -z "$USER_NAME" || -z "$PASSWORD" ]]; then
echo "Set both RELEASE_DEPLOY_USER and RELEASE_DEPLOY_PASSWORD when providing deploy credentials separately." >&2
exit 1
fi
fi
lftp_quote() {
local value="${1//\'/\'\\\'\'}"
printf "'%s'" "$value"
}
run_lftp() {
local transfer_command="$1"
{
printf 'set ftp:ssl-allow true\n'
printf 'set ftp:ssl-force true\n'
printf 'set ftp:ssl-protect-data true\n'
printf 'set net:max-retries 3\n'
printf 'set net:timeout 20\n'
if [[ -n "$USER_NAME" && -n "$PASSWORD" ]]; then
printf 'open -u %s,%s %s\n' "$(lftp_quote "$USER_NAME")" "$(lftp_quote "$PASSWORD")" "$(lftp_quote "$DEPLOY_URL")"
else
printf 'open %s\n' "$(lftp_quote "$DEPLOY_URL")"
fi
printf 'cd %s\n' "$(lftp_quote "$REMOTE_ROOT")"
printf '%s\n' "$transfer_command"
printf 'bye\n'
} | lftp -f /dev/stdin
}
upload_file() {
local source_file="$1"
local remote_file="$2"
if [[ -f "$source_file" ]]; then
lftp "$DEPLOY_URL" -e "set ftp:ssl-allow true; set net:max-retries 3; set net:timeout 20; cd \"$REMOTE_ROOT\"; put -O \"$(dirname "$remote_file")\" \"$source_file\" -o \"$(basename "$remote_file")\"; bye"
run_lftp "put -O $(lftp_quote "$(dirname "$remote_file")") $(lftp_quote "$source_file") -o $(lftp_quote "$(basename "$remote_file")")"
fi
}
for directory in assets resources favicons icons img sounds .well-known; do
if [[ -d "$DIST_DIR/$directory" ]]; then
lftp "$DEPLOY_URL" -e "set ftp:ssl-allow true; set net:max-retries 3; set net:timeout 20; cd \"$REMOTE_ROOT\"; mirror -R --only-newer --parallel=4 \"$DIST_DIR/$directory\" \"$directory\"; bye"
run_lftp "mirror -R --only-newer --parallel=4 $(lftp_quote "$DIST_DIR/$directory") $(lftp_quote "$directory")"
fi
done
+1 -1
View File
@@ -120,7 +120,7 @@ async function verifyShell(baseUrl, shellPath) {
async function verifyRelease(baseUrl) {
const expectedCommit = process.env.RELEASE_EXPECTED_COMMIT || process.env.GITHUB_SHA || "";
const expectedBuildId = process.env.RELEASE_EXPECTED_BUILD_ID || "";
const expectedBuildId = process.env.RELEASE_EXPECTED_BUILD_ID || process.env.RELEASE_BUILD_ID || "";
const manifest = await fetchJson(baseUrl, "release-manifest.json");
const releaseEntry = await fetchJson(baseUrl, "release-entry.json");
+17 -6
View File
@@ -118,6 +118,7 @@ const impersonatedUserRoleId = computed(() => {
const canGrantMissingPermissions = computed(() =>
hasSuperuserToken.value && !SessionUser.isSubuser.value && impersonatedUserRoleId.value !== null
);
const canInspectReleaseRuntime = computed(() => hasSuperuserToken.value || SessionUser.canAccessAdmin?.() === true);
const userDetailRows = computed(() => {
if (SessionUser.isSubuser.value) {
@@ -284,7 +285,12 @@ const resolvePingUrl = () => {
};
const activeApiUrl = computed(() => getReleaseRuntimeApiBaseUrl());
const releaseSessionSummary = computed(() => buildReleaseSessionSummary());
const activeApiUrlLabel = computed(() =>
canInspectReleaseRuntime.value ? activeApiUrl.value : "Restricted to release operators"
);
const releaseSessionSummary = computed(() =>
buildReleaseSessionSummary(undefined, { includeInfrastructureDetails: canInspectReleaseRuntime.value })
);
const measurePingLatency = async () => {
if (typeof fetch !== "function") {
@@ -646,9 +652,10 @@ onBeforeUnmount(() => {
</aside>
<aside class="request-queue-progress__side request-queue-progress__side--runtime" data-testid="request-queue-runtime-box">
<div class="request-queue-progress__section-title">Session release</div>
<div class="request-queue-progress__section-content request-queue-progress__section-content--release">
<ul class="request-queue-progress__meta-list">
<template v-if="canInspectReleaseRuntime">
<div class="request-queue-progress__section-title">Session release</div>
<div class="request-queue-progress__section-content request-queue-progress__section-content--release">
<ul class="request-queue-progress__meta-list">
<li class="request-queue-progress__meta-item">
<span class="request-queue-progress__meta-label">Channel</span>
<span
@@ -771,11 +778,15 @@ onBeforeUnmount(() => {
</li>
</ul>
<div class="request-queue-progress__subsection-title">Runtime details</div>
</div>
</template>
<div class="request-queue-progress__section-title">Runtime details</div>
<div class="request-queue-progress__section-content request-queue-progress__section-content--release">
<ul class="request-queue-progress__meta-list">
<li class="request-queue-progress__meta-item">
<span class="request-queue-progress__meta-label">API URL</span>
<span class="request-queue-progress__meta-value" :title="activeApiUrl">{{ activeApiUrl }}</span>
<span class="request-queue-progress__meta-value" :title="activeApiUrlLabel">{{ activeApiUrlLabel }}</span>
</li>
<li class="request-queue-progress__meta-item">
<span class="request-queue-progress__meta-label">Current host</span>
+17 -9
View File
@@ -590,7 +590,7 @@ const releaseServiceForKey = (serviceSet = null, key = "") => {
return isPlainRecord(service) ? service : null;
};
export const buildReleaseSessionSummary = (runtime = releaseRuntimeStateMutable) => {
export const buildReleaseSessionSummary = (runtime = releaseRuntimeStateMutable, options = {}) => {
const versions = isPlainRecord(runtime?.versions) ? runtime.versions : {};
const channel = isPlainRecord(runtime?.channel) ? runtime.channel : null;
const availability = isPlainRecord(runtime?.availability) ? runtime.availability : {};
@@ -600,16 +600,18 @@ export const buildReleaseSessionSummary = (runtime = releaseRuntimeStateMutable)
channel?.default_channel === true
|| channel?.default_channel === 1
|| String(channel?.slug || "").toLowerCase() === "stable";
const urls = releaseRuntimeUrlsForDisplay(runtime);
const includeInfrastructureDetails = options?.includeInfrastructureDetails === true;
const urls = includeInfrastructureDetails ? releaseRuntimeUrlsForDisplay(runtime) : { frontend: "", api: "" };
const frontendVersion = isPlainRecord(versions.frontend) ? versions.frontend : null;
const apiVersion = isPlainRecord(versions.api) ? versions.api : null;
const bundle = isPlainRecord(versions.bundle) ? versions.bundle : null;
const bundleId = versions.bundle_id || bundle?.id || null;
const serviceSet = isPlainRecord(versions.service_set)
const rawServiceSet = isPlainRecord(versions.service_set)
? versions.service_set
: isPlainRecord(bundle?.service_set)
? bundle.service_set
: null;
const serviceSet = includeInfrastructureDetails ? rawServiceSet : null;
const defaultSharedLabel = "Default/shared runtime";
const missingLabels = missing.map((key) => RELEASE_MISSING_LABELS[key] || key.replace(/_/g, " "));
@@ -628,15 +630,19 @@ export const buildReleaseSessionSummary = (runtime = releaseRuntimeStateMutable)
? "shared"
: firstFilledString(version?.status, url ? "active" : "unknown");
const primaryText = releaseVersionPrimaryText(version, fallback);
const secondaryText = includeInfrastructureDetails ? releaseVersionSecondaryText(version) : "";
const displayUrl = includeInfrastructureDetails ? url || "" : "";
return {
key,
label,
status,
tone: missingKey ? "warning" : releaseStatusTone(status),
primaryText: releaseVersionPrimaryText(version, fallback),
secondaryText: releaseVersionSecondaryText(version),
url: url || "",
title: [releaseVersionPrimaryText(version, fallback), releaseVersionSecondaryText(version), url]
primaryText,
secondaryText,
url: displayUrl,
title: [primaryText, secondaryText, displayUrl]
.filter(Boolean)
.join(" - "),
missingLabel: missingKey ? RELEASE_MISSING_LABELS[missingKey] || missingKey : "",
@@ -692,13 +698,15 @@ export const buildReleaseSessionSummary = (runtime = releaseRuntimeStateMutable)
bundleStatus: firstFilledString(bundle?.status, bundleId ? "active" : "shared"),
serviceSetLabel: serviceSet
? firstFilledString(serviceSet.name, serviceSet.slug, serviceSet.id ? `#${serviceSet.id}` : "Connected")
: defaultSharedLabel,
: rawServiceSet
? "Restricted to release operators"
: defaultSharedLabel,
missingLabels,
appRows: [
buildAppRow("frontend", "Frontend", frontendVersion, urls.frontend),
buildAppRow("api", "API", apiVersion, urls.api),
],
serviceRows: RELEASE_SERVICE_DEFINITIONS.map(buildServiceRow),
serviceRows: includeInfrastructureDetails || !rawServiceSet ? RELEASE_SERVICE_DEFINITIONS.map(buildServiceRow) : [],
};
};
@@ -1,5 +1,5 @@
<script setup>
import {ref, watch} from "vue";
import {computed, ref, watch} from "vue";
import { BLoading } from "buefy";
import { SessionUser } from "@/components/session/token/SessionUser.vue";
@@ -26,6 +26,9 @@ const washes = ref(0);
const outsideHours = ref(createEmptyOutsideHours());
const laneToggles = ref([]);
const laneToggleError = ref("");
const canManageLaneToggles = computed(() =>
SessionUser.hasPermission("admin") || SessionUser.hasPermission("list_department_wash_lanes")
);
const normalizeBoolean = (value, defaultValue = false) => {
if (typeof value === "boolean") {
@@ -214,7 +217,7 @@ const dognvaskToggleId = (lane) => `dognvask-${props.department_id}-${lane.id}`;
const laneDisplayName = (lane, index = 0) => lane.name || `Bane ${index + 1}`;
const isMachineToggleChecked = (lane) => Boolean(lane.machine_status_enabled);
const dognvaskHasWarning = (lane) => !lane.dognvask_configured;
const isDognvaskToggleChecked = (lane) => Boolean(lane.selfserve_enabled) && !dognvaskHasWarning(lane);
const isDognvaskToggleChecked = (lane) => Boolean(lane.selfserve_enabled);
const dognvaskWarnings = (lane) => Array.isArray(lane.dognvask_configuration_warnings) ? lane.dognvask_configuration_warnings : [];
const formatMachineStatusModifiedAt = (value) => {
if (!value) {
@@ -249,6 +252,11 @@ const applyLaneUpdate = (laneId, currentLane, update, savingPatch = {}) => {
};
const toggleMachineStatus = async (lane, event) => {
if (!canManageLaneToggles.value) {
event.target.checked = isMachineToggleChecked(lane);
return;
}
const enabled = event.target.checked;
const previousLane = { ...lane };
@@ -278,6 +286,11 @@ const toggleMachineStatus = async (lane, event) => {
};
const toggleDognvask = async (lane, event) => {
if (!canManageLaneToggles.value) {
event.target.checked = isDognvaskToggleChecked(lane);
return;
}
const enabled = event.target.checked;
const previousLane = { ...lane };
@@ -335,7 +348,7 @@ watch([selected_date, selected_date_to], () => {
type="checkbox"
:id="machineToggleId(lane)"
:checked="isMachineToggleChecked(lane)"
:disabled="lane.isSavingMachineStatus"
:disabled="!canManageLaneToggles || lane.isSavingMachineStatus"
@change="toggleMachineStatus(lane, $event)"
>
<div class="status-toggle-top">
@@ -371,7 +384,7 @@ watch([selected_date, selected_date_to], () => {
type="checkbox"
:id="dognvaskToggleId(lane)"
:checked="isDognvaskToggleChecked(lane)"
:disabled="lane.isSavingDognvask"
:disabled="!canManageLaneToggles || lane.isSavingDognvask"
@change="toggleDognvask(lane, $event)"
>
<div class="status-toggle-top">
@@ -2888,26 +2888,6 @@ function isolatedStackRequestedName() {
return String(bundleForm.service_set_name || `${channelSlug} isolated stack`).trim();
}
function isolatedStackIsComplete(set) {
return ["frontend", "api", "database", "redis", "minio"].every((item) => serviceSetStackItem(set, item));
}
function matchingIsolatedStackServiceSet() {
const channelId = Number(bundleChannelId() || 0);
const requestedSlug = safeStackSlug(isolatedStackRequestedName());
return (
serviceSets.value.find((set) => {
if (set?.mode !== "isolated_stack") {
return false;
}
if (channelId && Number(set.channel_id || 0) !== channelId) {
return false;
}
return safeStackSlug(set.name || set.slug || "") === requestedSlug || String(set.slug || "") === requestedSlug;
}) || null
);
}
function isolatedStackServiceName(app) {
const channelSlug = selectedBundleChannel()?.slug || "release";
const stackName = safeStackSlug(bundleForm.service_set_name || `${channelSlug}-isolated-stack`);
@@ -3150,19 +3130,6 @@ async function serviceSetIdForBundle() {
return sourceId;
}
if (isIsolatedStackMode.value) {
const existingStack = matchingIsolatedStackServiceSet();
if (existingStack?.id) {
if (!isolatedStackIsComplete(existingStack) && missingIsolatedDataServices(existingStack).length > 0) {
await completeReleaseServiceSetIsolatedDataServices(existingStack.id, {
deploy_data_targets: true,
});
await load();
}
return existingStack.id;
}
}
const isolatedTargets = isIsolatedStackMode.value
? {
frontend: await createIsolatedStackDeploymentTarget("frontend"),
@@ -3310,7 +3277,7 @@ async function saveChannel() {
}
async function saveAssignment() {
if (!assignmentForm.subject_type || !assignmentForm.subject_id) {
if (!canManage.value || !assignmentForm.subject_type || !assignmentForm.subject_id) {
return;
}
@@ -5366,7 +5333,7 @@ onMounted(async () => {
icon="fas fa-user-tag"
default-expanded
>
<div class="release-chip-row" data-testid="release-assignment-channel-suggestions">
<div v-if="canManage" class="release-chip-row" data-testid="release-assignment-channel-suggestions">
<b-button
v-for="channel in channels"
:key="channel.id"
@@ -5380,7 +5347,12 @@ onMounted(async () => {
</b-button>
</div>
<form class="release-form" data-testid="release-assignment-form" @submit.prevent="saveAssignment">
<form
v-if="canManage"
class="release-form"
data-testid="release-assignment-form"
@submit.prevent="saveAssignment"
>
<b-field :label="tr('assignments.subject')" :message="tr('assignments.subject_message')">
<ReleaseAssignmentSubjectAutocomplete
:model-value="assignmentSubjectSelection"
@@ -932,7 +932,7 @@ const syncActiveWashWithServer = async () => {
details?.session?.customer_number ?? details?.customer?.customer_number
);
const serverStillMatchesCurrentWash =
!!details?.in_progress && (!customerNumber || !serverCustomerNumber || serverCustomerNumber === customerNumber);
!!details?.in_progress && (!customerNumber || serverCustomerNumber === customerNumber);
if (!serverStillMatchesCurrentWash || isRecentlyCompletedActiveWash({ details, laneId })) {
const isWithinStartGracePeriod =
@@ -466,10 +466,10 @@ test.describe("Admin overview night washes", () => {
await expect(machineStatusTooltip).toContainText("2026");
}
await expect(page.locator("#dognvask-1-101")).toBeChecked();
await expect(page.locator("#dognvask-2-202")).not.toBeChecked();
await expect(page.locator("#dognvask-2-202")).toBeChecked();
await expect(page.getByTestId("overview-department-2-lane-202-dognvask").locator(".switch")).toHaveCSS(
"background-color",
"rgb(238, 75, 43)"
"rgb(146, 208, 80)"
);
await expect(page.getByTestId("overview-department-3-night-washes")).not.toContainText(
/mangler|Opening hours missing|Missing Hours/
+105 -4
View File
@@ -930,13 +930,13 @@ function completeIsolatedDataServices(state, serviceSet) {
return serviceSet;
}
async function boot(page, state = createReleaseState()) {
async function boot(page, state = createReleaseState(), options = {}) {
await page.addInitScript(() => {
window.localStorage.setItem("locale", "en");
});
await mockApi(page, {
authenticated: true,
permissions,
permissions: options.permissions || permissions,
sessionData: {
runtime_config: {
release: {
@@ -992,6 +992,8 @@ async function installReleaseMocks(page, state) {
if (pathname.endsWith("/superuser/releases/assignment-subjects") && method === "GET") {
const query = (url.searchParams.get("search") || "").toLowerCase();
state.assignmentSubjectRequests = state.assignmentSubjectRequests || [];
state.assignmentSubjectRequests.push(query);
const limit = Math.max(1, Math.min(10, Number(url.searchParams.get("limit") || 5)));
const subjects = [
{
@@ -2215,6 +2217,20 @@ test("overview filters channel health and issues by selected channel and app", a
await expect(issuePanel).not.toContainText("Composer install failed");
});
test("view-only release managers cannot search assignment subjects", async ({ page }) => {
const state = await boot(page, createReleaseState(), {
permissions: ["superuser_release_manager_view"],
});
await page.goto("/superuser/configuration/releases/assignments", { waitUntil: "domcontentloaded" });
await expectReleaseManagerReady(page);
await expect(page.getByTestId("release-section-assignments")).toBeVisible();
await expect(page.getByTestId("release-assignment-form")).toHaveCount(0);
await expect(page.getByTestId("release-assignment-channel-suggestions")).toHaveCount(0);
await expect(page.getByTestId("release-assignment-subject-search")).toHaveCount(0);
expect(state.assignmentSubjectRequests || []).toEqual([]);
});
test("superusers manage release settings, assignments, integrations, and sync operations", async ({ page }) => {
const state = await boot(page);
await page.goto("/superuser/configuration/releases/overview?channel=canary&app=api&branch=canary", {
@@ -2623,8 +2639,8 @@ test("isolated stack mode creates fresh Coolify app and data targets without att
const isolatedTargetCount = state.targets.filter((target) => target.deploy_context?.isolated_stack).length;
await page.getByTestId("release-bundle-deploy-submit").click();
await expect(page.getByTestId("release-created-bundle")).toContainText("Bundle deployed");
expect(state.serviceSets.filter((set) => set.mode === "isolated_stack")).toHaveLength(isolatedServiceSetCount);
expect(state.targets.filter((target) => target.deploy_context?.isolated_stack)).toHaveLength(isolatedTargetCount);
expect(state.serviceSets.filter((set) => set.mode === "isolated_stack")).toHaveLength(isolatedServiceSetCount + 1);
expect(state.targets.filter((target) => target.deploy_context?.isolated_stack)).toHaveLength(isolatedTargetCount + 2);
page.once("dialog", (dialog) => {
expect(dialog.message()).toContain("soft-deleted");
@@ -2636,6 +2652,91 @@ test("isolated stack mode creates fresh Coolify app and data targets without att
expect(state.serviceSets.find((set) => Number(set.id) === Number(isolatedSet.id))).toBeUndefined();
});
test("isolated stack mode creates a fresh stack even when an active stack has the requested name", async ({ page }) => {
const state = await boot(page);
state.targets[0].deploy_context = {
coolify_project_uuid: "project-internal",
};
state.targets.push({
id: state.nextTargetId++,
channel_id: 2,
channel_slug: "canary",
app: "api",
repository: "truckwash/backend-php",
branch: "canary",
coolify_instance_id: 3,
coolify_instance_label: "Production Coolify",
coolify_service_uuid: "api-canary-service",
health_url: "https://api-canary.example.test/ping",
auto_deploy: true,
deploy_context: {
coolify_project_uuid: "project-internal",
coolify_build_pack: "dockerfile",
},
});
const activeStack = {
id: state.nextServiceSetId++,
channel_id: 2,
channel_slug: "canary",
name: "Internal safe stack",
slug: "internal-safe-stack",
mode: "isolated_stack",
status: "isolated_stack",
active: true,
targets: {
frontend: {
id: 201,
channel_id: 2,
channel_slug: "canary",
app: "frontend",
repository: "truckwash/front-end-vue",
branch: DEFAULT_RELEASE_BRANCH,
deploy_context: { isolated_stack: true, production_data_attached: false },
},
api: {
id: 202,
channel_id: 2,
channel_slug: "canary",
app: "api",
repository: "truckwash/backend-php",
branch: "canary",
deploy_context: { isolated_stack: true, production_data_attached: false },
},
},
data_services: { database: null, redis: null, minio: null },
attached_bundles: [],
};
completeIsolatedDataServices(state, activeStack);
state.serviceSets.unshift(activeStack);
await page.goto("/superuser/configuration/releases/overview?channel=canary&app=all&branch=canary", {
waitUntil: "domcontentloaded",
});
await selectReleaseTab(page, "Deployments");
await expandActiveReleaseCategory(page);
const bundleFlow = page.getByTestId("release-bundle-flow");
await bundleFlow.getByTestId("release-bundle-channel").selectOption("2");
await bundleFlow.getByTestId("release-dataset-mode-isolated_stack").click();
await bundleFlow.getByPlaceholder("canary fresh data").fill("Internal safe stack");
await bundleFlow.getByRole("button", { name: "Next" }).click();
await bundleFlow.getByRole("button", { name: "Next" }).click();
await page.getByTestId("release-bundle-deploy-submit").click();
await expect(page.getByTestId("release-created-bundle")).toContainText("Bundle deployed");
const deployedServiceSetId = state.bundlePayloads[0].service_set_id;
expect(deployedServiceSetId).not.toBe(activeStack.id);
expect(state.serviceSets.find((set) => Number(set.id) === Number(activeStack.id))?.active).toBe(true);
expect(
state.serviceSets.filter((set) => set.mode === "isolated_stack" && set.slug === "internal-safe-stack")
).toHaveLength(2);
expect(
state.targets.filter((target) => target.deploy_context?.isolated_stack && target.id !== 201 && target.id !== 202)
).toHaveLength(2);
});
test("existing isolated stacks can add missing data services safely", async ({ page }) => {
const state = createReleaseState();
state.serviceSets.unshift({
@@ -8,6 +8,7 @@ const getDepartmentMock = vi.hoisted(() => vi.fn());
const getLaneStatusTogglesMock = vi.hoisted(() => vi.fn());
const setMachineStatusEnabledMock = vi.hoisted(() => vi.fn());
const setLaneSelfServeEnabledMock = vi.hoisted(() => vi.fn());
const hasPermissionMock = vi.hoisted(() => vi.fn());
const laneHelpers = vi.hoisted(() => {
const requiredFields = [
"relay_in_id",
@@ -67,6 +68,7 @@ vi.mock("@/components/session/token/SessionUser.vue", () => ({
},
},
},
hasPermission: hasPermissionMock,
functions: {
date: {
isToday: () => false,
@@ -141,6 +143,8 @@ describe("Department overview period sync behavior", () => {
getLaneStatusTogglesMock.mockReset();
setMachineStatusEnabledMock.mockReset();
setLaneSelfServeEnabledMock.mockReset();
hasPermissionMock.mockReset();
hasPermissionMock.mockImplementation((permission) => permission === "list_department_wash_lanes");
getDepartmentMock.mockResolvedValue({ id: 20, name: "Dept 20" });
getLaneStatusTogglesMock.mockResolvedValue({ data: { data: [] } });
@@ -367,16 +371,27 @@ describe("Department overview period sync behavior", () => {
},
},
});
setLaneSelfServeEnabledMock.mockResolvedValueOnce({
data: {
setLaneSelfServeEnabledMock
.mockResolvedValueOnce({
data: {
lane: {
...laneWithCompleteSetup,
selfserve_enabled: false,
data: {
lane: {
...laneWithCompleteSetup,
selfserve_enabled: false,
},
},
},
},
});
})
.mockResolvedValueOnce({
data: {
data: {
lane: {
...laneWithMissingSetup,
selfserve_enabled: false,
},
},
},
});
const wrapper = mount(DepartmentDailyReportSmall, {
props: {
@@ -405,7 +420,7 @@ describe("Department overview period sync behavior", () => {
expect(wrapper.find('[data-testid="overview-department-20-lane-21-dognvask-info"]').exists()).toBe(true);
expect(wrapper.find('[data-testid="overview-department-20-lane-22-dognvask-warning"]').exists()).toBe(true);
expect(wrapper.find("#dognvask-20-21").element.checked).toBe(true);
expect(wrapper.find("#dognvask-20-22").element.checked).toBe(false);
expect(wrapper.find("#dognvask-20-22").element.checked).toBe(true);
await wrapper.find("#machine-status-20-22").setValue(true);
await flushAll();
@@ -416,9 +431,72 @@ describe("Department overview period sync behavior", () => {
expect(setLaneSelfServeEnabledMock).toHaveBeenCalledWith(21, false);
expect(wrapper.find("#dognvask-20-21").element.checked).toBe(false);
await wrapper.find("#dognvask-20-22").setValue(false);
await flushAll();
expect(setLaneSelfServeEnabledMock).toHaveBeenCalledWith(22, false);
expect(setLaneSelfServeEnabledMock).toHaveBeenCalledTimes(2);
expect(wrapper.find("#dognvask-20-22").element.checked).toBe(false);
await wrapper.find("#dognvask-20-22").setValue(true);
await flushAll();
expect(setLaneSelfServeEnabledMock).toHaveBeenCalledTimes(1);
expect(setLaneSelfServeEnabledMock).toHaveBeenCalledTimes(2);
expect(wrapper.find("#dognvask-20-22").element.checked).toBe(false);
});
it("disables lane mutation toggles for daily-report users without wash-lane permission", async () => {
hasPermissionMock.mockImplementation((permission) => permission === "list_department_daily_reports");
getLaneStatusTogglesMock.mockResolvedValueOnce({
data: {
data: [
{
id: 21,
department: 20,
name: "T1",
status: "AVAILABLE",
machine_status_enabled: true,
selfserve_enabled: true,
relay_in_id: "in-1",
relay_out_id: "out-1",
relay_machine_id: "machine-1",
relay_machine_program_picker_id: "picker-1",
relay_machine_cleaner_id: "cleaner-1",
dynamic_image_id: 1,
machine_type_id: 1,
dognvask_configured: true,
dognvask_configuration_warnings: [],
},
],
},
});
const wrapper = mount(DepartmentDailyReportSmall, {
props: {
department_id: 20,
},
global: {
mocks: {
$t: (value) => value,
},
stubs: {
BLoading: true,
},
},
});
await flushAll();
await flushAll();
const machineToggle = wrapper.find("#machine-status-20-21");
const dognvaskToggle = wrapper.find("#dognvask-20-21");
expect(machineToggle.element.disabled).toBe(true);
expect(dognvaskToggle.element.disabled).toBe(true);
await machineToggle.trigger("change");
await dognvaskToggle.trigger("change");
await flushAll();
expect(setMachineStatusEnabledMock).not.toHaveBeenCalled();
expect(setLaneSelfServeEnabledMock).not.toHaveBeenCalled();
});
});
+65
View File
@@ -1281,6 +1281,71 @@ describe("MyWashStart", () => {
);
});
it("clears restored in-progress state when active wash refresh cannot verify authenticated ownership", async () => {
mocks.restoredProgressPayload = {
washInProgress: true,
washLaneId: 7,
washStartTime: Date.now() - 30_000,
licensePlateInput: "AB12345",
vehicleTypeSelect: 2,
radioWashType: "Manual",
radioLaneOption: 7,
customerNumberInput: 12345679,
isForcingNearestDepartment: false,
forceNearestDepartmentEvaluationId: 0,
answers: {},
completedTasks: {},
currentStep: 4,
};
mocks.sessionRequest.mockImplementation(async (path, method, payload) => {
if (path === "/modules/self-serve/lane/wash/in-progress" && method === "GET" && payload?.lane_id === 7) {
return {
data: {
data: {
lane_id: 7,
in_progress: true,
session: {
id: 909,
reg: "VICTIM42",
customer_number: null,
vehicle_type_id: 2,
machine_relay_enabled: true,
wash_started_at: "2026-04-28 10:15:00",
},
customer: {},
vehicle: { reg: "VICTIM42", type: 2 },
},
},
};
}
return undefined;
});
const wrapper = mountWithApp(MyWashStart, {
global: {
stubs: stubComponents,
},
});
await flushPromises();
await nextTick();
await flushPromises();
expect(mocks.sessionRequest).toHaveBeenCalledWith("/modules/self-serve/lane/wash/in-progress", "GET", {
lane_id: 7,
});
expect(mocks.fetchWashSummary).not.toHaveBeenCalledWith({ session_id: 909, vehicle_type: 2 }, false);
expect(mocks.saveProgress).not.toHaveBeenCalledWith("serverActiveWashRefresh");
expect(mocks.stopElapsedTimer).toHaveBeenCalled();
expect(mocks.clearProgress).toHaveBeenCalled();
expect(wrapper.get('[data-testid="self-serve-bottom-actions"]').attributes("style") || "").toContain(
"display: none"
);
wrapper.unmount();
});
it("clears restored in-progress state when active wash refresh says the lane is no longer in progress", async () => {
mocks.restoredProgressPayload = {
washInProgress: true,
+50 -3
View File
@@ -122,7 +122,7 @@ describe("release timeline runtime", () => {
availability: { configured: true, missing: [], status: "ready" },
});
const summary = buildReleaseSessionSummary();
const summary = buildReleaseSessionSummary(undefined, { includeInfrastructureDetails: true });
expect(releaseRuntimeState.versions.service_set.name).toBe("Canary isolated stack");
expect(summary.channelLabel).toBe("Canary");
@@ -143,6 +143,53 @@ describe("release timeline runtime", () => {
expect(summary.serviceRows.find((row) => row.key === "minio")?.status).toBe("healthy");
});
it("redacts service set infrastructure from release summaries by default", () => {
const summary = buildReleaseSessionSummary({
trace_id: "trace-redacted",
channel: { slug: "canary", name: "Canary", default_channel: false },
versions: {
frontend: {
version_label: "frontend-canary",
commit_sha: "c0ffee0000001111222233334444555566667777",
repository: "truckwash/front-end-vue",
branch: "release/canary",
status: "deployed",
},
api: { version_label: "api-canary", status: "deployed" },
service_set: {
id: 53,
name: "Canary isolated stack",
stack: {
database: {
id: 54,
resource_name: "canary-db",
resource_uuid: "database-resource-uuid",
coolify_service_uuid: "database-service-uuid",
health_url: "https://coolify.internal/health/db",
},
},
},
bundle_id: 31,
},
urls: {
frontend_base_url: "https://api-v2.truckwash.io/canary/frontend",
api_base_url: "https://api-v2.truckwash.io/canary/api",
},
availability: { configured: true, missing: [], status: "ready" },
});
expect(summary.serviceSetLabel).toBe("Restricted to release operators");
expect(summary.serviceRows).toEqual([]);
expect(summary.appRows.find((row) => row.key === "frontend")).toMatchObject({
secondaryText: "",
url: "",
});
expect(JSON.stringify(summary)).not.toContain("canary-db");
expect(JSON.stringify(summary)).not.toContain("database-resource-uuid");
expect(JSON.stringify(summary)).not.toContain("coolify.internal");
expect(JSON.stringify(summary)).not.toContain("truckwash/front-end-vue#release/canary");
});
it("marks stable sessions without release bundle data as shared runtime", () => {
const summary = buildReleaseSessionSummary({
trace_id: "trace-stable",
@@ -232,7 +279,7 @@ describe("release timeline runtime", () => {
expect(resolveReleaseApiUrl("/orders")).not.toBe("https://attacker.example/api/orders");
});
it("keeps same-origin release API URLs visible in the session summary", () => {
it("keeps same-origin release API URLs visible in privileged session summaries", () => {
configureReleaseRuntime({
trace_id: "trace-local-api",
channel: { slug: "canary", name: "Canary" },
@@ -246,7 +293,7 @@ describe("release timeline runtime", () => {
},
});
const summary = buildReleaseSessionSummary();
const summary = buildReleaseSessionSummary(undefined, { includeInfrastructureDetails: true });
expect(releaseRuntimeState.apiBaseUrl).toBe("/api");
expect(getReleaseRuntimeApiBaseUrl()).toBe("/api");
+56 -1
View File
@@ -256,7 +256,8 @@ describe("RequestQueueProgress", () => {
expect(runtimeBox.text()).toContain("Ingoing bandwidth");
});
it("shows the active session release bundle, app URLs, and connected services", async () => {
it("shows the active session release bundle, app URLs, and connected services to release operators", async () => {
localStorage.setItem("superuser_token", "su-token");
configureReleaseRuntime({
generated_at: "2026-05-19T09:30:00.000Z",
trace_id: "trace-request-panel",
@@ -317,6 +318,60 @@ describe("RequestQueueProgress", () => {
expect(wrapper.get("[data-testid='request-queue-release-service-minio']").text()).toContain("canary-minio #56");
});
it("hides release infrastructure metadata from low-privileged subusers", async () => {
SessionUser.isSubuser.value = true;
SessionUser.subuser.grants.value = [{ billing_customer_number: 12345, permissions: ["order_read"] }];
SessionUser.subuser.selectedGrantCustomerNumber.value = 12345;
configureReleaseRuntime({
generated_at: "2026-05-19T09:30:00.000Z",
trace_id: "trace-low-privilege",
channel: { slug: "canary", name: "Canary", default_channel: false },
versions: {
frontend: {
version_label: "frontend-canary",
repository: "truckwash/front-end-vue",
branch: "release/canary",
status: "deployed",
},
api: { version_label: "api-canary", status: "deployed" },
service_set: {
id: 53,
name: "Canary isolated stack",
stack: {
database: {
id: 54,
resource_name: "canary-db",
resource_uuid: "database-resource-uuid",
coolify_service_uuid: "database-service-uuid",
health_url: "https://coolify.internal/health/db",
},
},
},
bundle_id: 31,
},
urls: {
frontend_base_url: "https://api-v2.truckwash.io/canary/frontend",
api_base_url: "https://api-v2.truckwash.io/canary/api",
},
});
const wrapper = mount(RequestQueueProgress);
await triggerShiftTriplePress();
await flushManyMicrotasks();
const runtimeBox = wrapper.get("[data-testid='request-queue-runtime-box']");
expect(runtimeBox.text()).toContain("Runtime details");
expect(runtimeBox.text()).not.toContain("Session release");
expect(runtimeBox.text()).not.toContain("trace-low-privilege");
expect(runtimeBox.text()).not.toContain("Canary isolated stack");
expect(runtimeBox.text()).not.toContain("canary-db");
expect(runtimeBox.text()).not.toContain("database-resource-uuid");
expect(runtimeBox.text()).not.toContain("coolify.internal");
expect(runtimeBox.text()).not.toContain("api-v2.truckwash.io");
expect(runtimeBox.text()).toContain("Restricted to release operators");
expect(wrapper.find("[data-testid='request-queue-release-service-database']").exists()).toBe(false);
});
it("shows subuser details in the user box", async () => {
SessionUser.isSubuser.value = true;
SessionUser.subuser.name.value = "Sub User";
+11 -1
View File
@@ -7,7 +7,7 @@ describe("Vite API proxy", () => {
expect(options.target).toBe("https://api-v2.truckwash.io");
expect(options.changeOrigin).toBe(true);
expect(options.secure).toBe(false);
expect(options.secure).toBe(true);
expect(options.rewrite("/api/ping")).toBe("/master/api/ping");
expect(options.rewrite("/api/release/runtime")).toBe("/master/api/release/runtime");
expect(options.rewrite("/api")).toBe("/master/api");
@@ -32,6 +32,16 @@ describe("Vite API proxy", () => {
expect(options.rewrite("/api")).toBe("/canary/api");
});
it("allows disabling TLS verification for explicitly trusted local gateways", () => {
const options = createApiProxyOptions({
VITE_API_PROXY_TARGET: "https://local-api.test",
VITE_API_PROXY_SECURE: "false",
});
expect(options.target).toBe("https://local-api.test");
expect(options.secure).toBe(false);
});
it("allows disabling prefix stripping for compatible local gateways", () => {
const options = createApiProxyOptions({
VITE_API_PROXY_TARGET: "http://localhost",
+6 -1
View File
@@ -389,6 +389,10 @@ function normalizeProxyBasePath(value) {
return normalized ? `/${normalized}` : ''
}
function parseProxySecure(value) {
return String(value || '').trim().toLowerCase() !== 'false'
}
function rewriteApiProxyPath(requestPath, basePath = '') {
const pathWithoutApiPrefix = String(requestPath || '/').replace(/^\/api(?=\/|\?|$)/, '') || '/'
if (!basePath) {
@@ -404,6 +408,7 @@ export function createApiProxyOptions(env = process.env) {
const stripPrefix = env.VITE_API_PROXY_STRIP_PREFIX !== 'false'
const configuredTarget = String(env.VITE_API_PROXY_TARGET || '').trim()
const target = configuredTarget || DEFAULT_API_PROXY_TARGET
const secure = parseProxySecure(env.VITE_API_PROXY_SECURE)
const basePath = env.VITE_API_PROXY_BASE_PATH !== undefined
? normalizeProxyBasePath(env.VITE_API_PROXY_BASE_PATH)
: configuredTarget
@@ -413,7 +418,7 @@ export function createApiProxyOptions(env = process.env) {
return {
target,
changeOrigin: true,
secure: false,
secure,
...(stripPrefix
? {
rewrite: (requestPath) => rewriteApiProxyPath(requestPath, basePath)