Merge origin/master and resolve release timeline spec conflict
This commit is contained in:
@@ -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 }}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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");
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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) : [],
|
||||
};
|
||||
};
|
||||
|
||||
|
||||
+17
-4
@@ -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">
|
||||
|
||||
+8
-36
@@ -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/
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user