Enhance session management by normalizing session payload and resetting session state on logout. Update login redirect logic and improve runtime API URL handling for selected release channels.

This commit is contained in:
Jeppe Bundgaard
2026-05-26 17:28:21 +02:00
parent 8ab8915429
commit 184ef5670c
12 changed files with 1038 additions and 226 deletions
+19 -2
View File
@@ -32,6 +32,24 @@ const successMessage = ref('');
const store = useStore(); const store = useStore();
const router = useRouter(); const router = useRouter();
const resolveLoginRedirectPath = () => {
const redirectQuery = router.currentRoute.value.query.redirect;
const redirectValue = Array.isArray(redirectQuery) ? redirectQuery[0] : redirectQuery;
if (!redirectValue) {
return "/redirect";
}
try {
const redirectUrl = new URL(redirectValue, window.location.origin);
if (redirectUrl.origin !== window.location.origin) {
return "/redirect";
}
return `${redirectUrl.pathname}${redirectUrl.search}${redirectUrl.hash}`;
} catch {
return "/redirect";
}
};
const login = async () => { const login = async () => {
try { try {
const response = await axios.post(API_URL + '/auth/login', { const response = await axios.post(API_URL + '/auth/login', {
@@ -55,8 +73,7 @@ const login = async () => {
successMessage.value = "Du er nu logget ind!" successMessage.value = "Du er nu logget ind!"
// Wait 500ms before reloading the page to show the success message // Wait 500ms before reloading the page to show the success message
setTimeout(() => { setTimeout(() => {
// Reload the page to update the UI window.location.assign(resolveLoginRedirectPath());
router.go(0);
}, 500); }, 500);
// window.location.reload(); // window.location.reload();
} catch (e) { } catch (e) {
+142 -110
View File
@@ -49,6 +49,7 @@ import { SubuserGrants } from "@/components/session/token/SessionUser/Objects/Su
import { Subusers } from "@/components/session/token/SessionUser/Objects/Subusers.vue"; import { Subusers } from "@/components/session/token/SessionUser/Objects/Subusers.vue";
import { configureReleaseRuntime } from "@/services/releaseTimeline.js"; import { configureReleaseRuntime } from "@/services/releaseTimeline.js";
import { fetchReleaseRuntime } from "@/services/releaseBootstrap.js"; import { fetchReleaseRuntime } from "@/services/releaseBootstrap.js";
import { normalizeSessionPayload } from "@/services/sessionPayload.js";
import { import {
isReleaseChannelApiAvailabilityError, isReleaseChannelApiAvailabilityError,
markReleaseChannelApiUnavailable, markReleaseChannelApiUnavailable,
@@ -96,6 +97,19 @@ const hydrateSessionFromStorage = () => {
return true; return true;
}; };
const clearStoredSession = () => {
if (typeof window === "undefined") {
return;
}
try {
window.localStorage.removeItem("token");
window.localStorage.removeItem("is_subuser");
} catch {
// Storage can be unavailable in test or private browsing contexts.
}
};
const applyReleaseRuntimeConfig = (runtime) => { const applyReleaseRuntimeConfig = (runtime) => {
const normalizedRuntime = runtime || {}; const normalizedRuntime = runtime || {};
configureReleaseRuntime(normalizedRuntime); configureReleaseRuntime(normalizedRuntime);
@@ -142,6 +156,96 @@ export const refreshReleaseRuntime = async ({ throwOnError = false } = {}) => {
} }
}; };
const resetSessionState = () => {
SessionUser.user.id.value = null;
SessionUser.user.customer_number.value = null;
SessionUser.user.display_name.value = null;
SessionUser.user.group_id.value = null;
SessionUser.user.email.value = null;
SessionUser.user.phone.number.value = null;
SessionUser.user.phone.country_code.value = null;
SessionUser.user.notifications.wash_certificate_email.value = null;
SessionUser.user.notifications.email_notifications_enabled.value = null;
SessionUser.user.notifications.sms_notifications_enabled.value = null;
SessionUser.user.created_at.value = null;
SessionUser.user.updated_at.value = null;
SessionUser.user.cached_at.value = null;
SessionUser.permissions.value = [];
SessionUser.token.value = null;
SessionUser.authenticated.value = false;
SessionUser.isSubuser.value = false;
SessionUser.initiated.value = false;
SessionUser.subuser.id.value = null;
SessionUser.subuser.username.value = null;
SessionUser.subuser.name.value = null;
SessionUser.subuser.email.value = null;
SessionUser.subuser.phone.country_code.value = null;
SessionUser.subuser.phone.number.value = null;
SessionUser.subuser.grants.value = [];
SessionUser.subuser.created_at.value = null;
SessionUser.subuser.updated_at.value = null;
SessionUser.subuser.suspended_at.value = null;
SessionUser.subuser.cached_at.value = null;
SessionUser.subuser.selectedGrantCustomerNumber.value = null;
if (typeof window !== "undefined") {
try {
window.localStorage.removeItem("selected_customer_number");
} catch {
// Storage is optional for the in-memory reset.
}
}
SessionUser.economicData.customerNumber.value = null;
SessionUser.economicData.name.value = null;
SessionUser.economicData.address.value = null;
SessionUser.economicData.zip.value = null;
SessionUser.economicData.city.value = null;
SessionUser.economicData.mobilePhone.value = null;
SessionUser.economicData.email.value = null;
SessionUser.economicData.cvr.value = null;
SessionUser.economicData.currency.value = null;
SessionUser.economicData.country.value = null;
SessionUser.economicData.cached_at.value = null;
SessionUser.runtimeConfig.economic.transactionDraftCustomerNumber.value = null;
SessionUser.runtimeConfig.economic.defaultDistributionDepartmentId.value = null;
SessionUser.runtimeConfig.release.traceId.value = null;
SessionUser.runtimeConfig.release.channel.value = null;
SessionUser.runtimeConfig.release.availableChannels.value = [];
SessionUser.runtimeConfig.release.versions.value = { frontend: null, api: null, bundle_id: null };
SessionUser.runtimeConfig.release.frontendBaseUrl.value = null;
SessionUser.runtimeConfig.release.apiBaseUrl.value = null;
SessionUser.runtimeConfig.release.availability.value = {
configured: true,
missing: [],
status: "ready",
explicit: false,
};
SessionUser.runtimeConfig.release.capturePolicy.value = {
enabled: false,
capture_level: "metadata",
all_failure_metadata: true,
retention_days: 14,
};
setReleaseChannelSwitchNoticePrincipal("");
configureReleaseRuntime({});
};
export const refreshSessionData = async () => {
if (!hydrateSessionFromStorage()) {
resetSessionState();
return null;
}
if (SessionUser.isSubuser.value) {
return await getSubuserSessionData();
}
return await getSessionData();
};
/** /**
* Initiate the user session on app start * Initiate the user session on app start
* @returns {Promise<void>} * @returns {Promise<void>}
@@ -296,50 +400,50 @@ export const getSubuserSessionData = async () => {
export const getSessionData = async () => { export const getSessionData = async () => {
return await authenticatedRequest("/auth/session", "GET", releaseChannelRuntimeRequestParams()) return await authenticatedRequest("/auth/session", "GET", releaseChannelRuntimeRequestParams())
.then((response) => { .then((response) => {
SessionUser.user.id.value = response.data.data.id; const session = normalizeSessionPayload(response?.data?.data);
SessionUser.user.customer_number.value = response.data.data.customer_number;
SessionUser.user.group_id.value = response.data.data.group_id; SessionUser.user.id.value = session.id;
SessionUser.user.email.value = response.data.data.email; SessionUser.user.customer_number.value = session.customer_number;
SessionUser.user.phone.number.value = response.data.data.phone.number; SessionUser.user.group_id.value = session.group_id;
SessionUser.user.phone.country_code.value = response.data.data.phone.country_code; SessionUser.user.email.value = session.email;
SessionUser.user.notifications.wash_certificate_email.value = SessionUser.user.phone.number.value = session.phone.number;
response.data.data.notifications.wash_certificate_email; SessionUser.user.phone.country_code.value = session.phone.country_code;
SessionUser.user.notifications.wash_certificate_email.value = session.notifications.wash_certificate_email;
SessionUser.user.notifications.email_notifications_enabled.value = SessionUser.user.notifications.email_notifications_enabled.value =
response.data.data.notifications.email_notifications_enabled; session.notifications.email_notifications_enabled;
SessionUser.user.notifications.sms_notifications_enabled.value = SessionUser.user.notifications.sms_notifications_enabled.value = session.notifications.sms_notifications_enabled;
response.data.data.notifications.sms_notifications_enabled; SessionUser.user.created_at.value = session.created_at;
SessionUser.user.created_at.value = response.data.data.created_at; SessionUser.user.updated_at.value = session.updated_at;
SessionUser.user.updated_at.value = response.data.data.updated_at; SessionUser.user.display_name.value = session.display_name;
SessionUser.user.display_name.value = response.data.data.display_name;
setReleaseChannelSwitchNoticePrincipal( setReleaseChannelSwitchNoticePrincipal(
`user:${response.data.data.id || response.data.data.customer_number || response.data.data.email || "unknown"}` `user:${session.id || session.customer_number || session.email || "unknown"}`
); );
// Set the last cached time to now, this is used to determine if the user's data is outdated // Set the last cached time to now, this is used to determine if the user's data is outdated
SessionUser.user.cached_at.value = new Date(); SessionUser.user.cached_at.value = new Date();
SessionUser.permissions.value = response.data.data.permissions; SessionUser.permissions.value = session.permissions;
SessionUser.runtimeConfig.economic.transactionDraftCustomerNumber.value = normalizePositiveInteger( SessionUser.runtimeConfig.economic.transactionDraftCustomerNumber.value = normalizePositiveInteger(
response?.data?.data?.runtime_config?.economic?.transaction_draft_customer_number session.runtime_config.economic.transaction_draft_customer_number
); );
SessionUser.runtimeConfig.economic.defaultDistributionDepartmentId.value = normalizePositiveInteger( SessionUser.runtimeConfig.economic.defaultDistributionDepartmentId.value = normalizePositiveInteger(
response?.data?.data?.runtime_config?.economic?.default_distribution_department_id session.runtime_config.economic.default_distribution_department_id
); );
applyReleaseRuntimeConfig(response?.data?.data?.runtime_config?.release || {}); applyReleaseRuntimeConfig(session.runtime_config.release);
// E-conomic data is only fetched if the array isn't empty // E-conomic data is only fetched if the array isn't empty
if (response.data.data.economic_customer.length > 0) { if (session.economic_customer) {
SessionUser.economicData.customerNumber.value = response.data.data.economic_customer.customerNumber; SessionUser.economicData.customerNumber.value = session.economic_customer.customerNumber;
SessionUser.economicData.name.value = response.data.data.economic_customer.name; SessionUser.economicData.name.value = session.economic_customer.name;
// Set the display name to the e-conomic name if it's empty // Set the display name to the e-conomic name if it's empty
if (SessionUser.user.display_name.value === null || SessionUser.user.display_name.value === "Unnamed") { if (SessionUser.user.display_name.value === null || SessionUser.user.display_name.value === "Unnamed") {
SessionUser.user.display_name.value = response.data.data.economic_customer.name; SessionUser.user.display_name.value = session.economic_customer.name;
} }
SessionUser.economicData.address.value = response.data.data.economic_customer.address; SessionUser.economicData.address.value = session.economic_customer.address;
SessionUser.economicData.zip.value = response.data.data.economic_customer.zip; SessionUser.economicData.zip.value = session.economic_customer.zip;
SessionUser.economicData.city.value = response.data.data.economic_customer.city; SessionUser.economicData.city.value = session.economic_customer.city;
SessionUser.economicData.mobilePhone.value = response.data.data.economic_customer.mobilePhone; SessionUser.economicData.mobilePhone.value = session.economic_customer.mobilePhone;
SessionUser.economicData.email.value = response.data.data.economic_customer.email; SessionUser.economicData.email.value = session.economic_customer.email;
SessionUser.economicData.cvr.value = response.data.data.economic_customer.corporateIdentificationNumber; SessionUser.economicData.cvr.value = session.economic_customer.corporateIdentificationNumber;
SessionUser.economicData.currency.value = response.data.data.economic_customer.currency; SessionUser.economicData.currency.value = session.economic_customer.currency;
SessionUser.economicData.country.value = response.data.data.economic_customer.country; SessionUser.economicData.country.value = session.economic_customer.country;
SessionUser.economicData.cached_at.value = new Date(); SessionUser.economicData.cached_at.value = new Date();
} }
SessionUser.initiated.value = true; SessionUser.initiated.value = true;
@@ -371,22 +475,13 @@ export const getSessionData = async () => {
*/ */
export const destroySession = async () => { export const destroySession = async () => {
return await authenticatedRequest("/auth/logout", "GET") return await authenticatedRequest("/auth/logout", "GET")
.then(() => {
localStorage.removeItem("token");
localStorage.removeItem("is_subuser");
SessionUser.forceRefresh();
})
.catch((error) => { .catch((error) => {
parseError(error, "auth"); parseError(error, "auth");
localStorage.removeItem("token");
localStorage.removeItem("is_subuser");
SessionUser.forceRefresh();
console.error(error); console.error(error);
}) })
.finally(() => { .finally(() => {
localStorage.removeItem("token"); clearStoredSession();
localStorage.removeItem("is_subuser"); resetSessionState();
SessionUser.forceRefresh();
}); });
}; };
@@ -394,9 +489,8 @@ export const destroySession = async () => {
* Force sign out the user * Force sign out the user
*/ */
export const forceClearSession = () => { export const forceClearSession = () => {
localStorage.removeItem("token"); clearStoredSession();
localStorage.removeItem("is_subuser"); resetSessionState();
SessionUser.forceRefresh();
}; };
/** /**
@@ -726,70 +820,7 @@ export const SessionUser = {
}, },
/** Reset the user's session data (Cache) */ /** Reset the user's session data (Cache) */
forceRefresh: () => { forceRefresh: () => {
SessionUser.user.id.value = null; resetSessionState();
SessionUser.user.customer_number.value = null;
SessionUser.user.display_name.value = null;
SessionUser.user.group_id.value = null;
SessionUser.user.email.value = null;
SessionUser.user.phone.number.value = null;
SessionUser.user.phone.country_code.value = null;
SessionUser.user.notifications.wash_certificate_email.value = null;
SessionUser.user.notifications.email_notifications_enabled.value = null;
SessionUser.user.notifications.sms_notifications_enabled.value = null;
SessionUser.user.created_at.value = null;
SessionUser.user.updated_at.value = null;
SessionUser.user.cached_at.value = null;
SessionUser.permissions.value = [];
SessionUser.authenticated.value = false;
SessionUser.isSubuser.value = false;
// Clear subuser data
SessionUser.subuser.id.value = null;
SessionUser.subuser.username.value = null;
SessionUser.subuser.name.value = null;
SessionUser.subuser.email.value = null;
SessionUser.subuser.phone.country_code.value = null;
SessionUser.subuser.phone.number.value = null;
SessionUser.subuser.grants.value = [];
SessionUser.subuser.created_at.value = null;
SessionUser.subuser.updated_at.value = null;
SessionUser.subuser.suspended_at.value = null;
SessionUser.subuser.cached_at.value = null;
SessionUser.subuser.selectedGrantCustomerNumber.value = null;
localStorage.removeItem("selected_customer_number");
// Clear economic data
SessionUser.economicData.customerNumber.value = null;
SessionUser.economicData.name.value = null;
SessionUser.economicData.address.value = null;
SessionUser.economicData.zip.value = null;
SessionUser.economicData.city.value = null;
SessionUser.economicData.mobilePhone.value = null;
SessionUser.economicData.email.value = null;
SessionUser.economicData.cvr.value = null;
SessionUser.economicData.currency.value = null;
SessionUser.economicData.country.value = null;
SessionUser.economicData.cached_at.value = null;
SessionUser.runtimeConfig.economic.transactionDraftCustomerNumber.value = null;
SessionUser.runtimeConfig.economic.defaultDistributionDepartmentId.value = null;
SessionUser.runtimeConfig.release.traceId.value = null;
SessionUser.runtimeConfig.release.channel.value = null;
SessionUser.runtimeConfig.release.availableChannels.value = [];
SessionUser.runtimeConfig.release.versions.value = { frontend: null, api: null, bundle_id: null };
SessionUser.runtimeConfig.release.frontendBaseUrl.value = null;
SessionUser.runtimeConfig.release.apiBaseUrl.value = null;
SessionUser.runtimeConfig.release.availability.value = {
configured: true,
missing: [],
status: "ready",
explicit: false,
};
SessionUser.runtimeConfig.release.capturePolicy.value = {
enabled: false,
capture_level: "metadata",
all_failure_metadata: true,
retention_days: 14,
};
setReleaseChannelSwitchNoticePrincipal("");
getSessionData();
}, },
auth: { auth: {
/** Authenticate the user */ /** Authenticate the user */
@@ -798,7 +829,7 @@ export const SessionUser = {
authenticateSubuser: authenticateSubuser, authenticateSubuser: authenticateSubuser,
/** Logout the user */ /** Logout the user */
logout: destroySession, logout: destroySession,
/** Force clear the user's session WARNING: This will not destroy the user's token */ /** Force clear the user's local session state and stored token */
forceClearSession: forceClearSession, forceClearSession: forceClearSession,
/** reCAPTCHA */ /** reCAPTCHA */
reCAPTCHA: reCAPTCHA, reCAPTCHA: reCAPTCHA,
@@ -1166,6 +1197,7 @@ export const SessionUser = {
editField: EditFieldForm, editField: EditFieldForm,
getSessionData: getSessionData, getSessionData: getSessionData,
getSubuserSessionData: getSubuserSessionData, getSubuserSessionData: getSubuserSessionData,
refreshSessionData: refreshSessionData,
refreshReleaseRuntime: refreshReleaseRuntime, refreshReleaseRuntime: refreshReleaseRuntime,
initiateOnAppStart: initiateOnAppStart, initiateOnAppStart: initiateOnAppStart,
checkForUpdates() { checkForUpdates() {
+14 -23
View File
@@ -1,4 +1,4 @@
import { API_URL, IS_DEV, RELEASE_PUBLIC_GATEWAY_API_URL, RELEASE_SOURCE, RELEASE_SOURCE_ENV } from "@/config.js"; import { API_URL, IS_DEV, RELEASE_MANAGER_CONTROL_API_URL, RELEASE_SOURCE, RELEASE_SOURCE_ENV } from "@/config.js";
import { buildReleaseHeaders } from "@/services/releaseHeaders.js"; import { buildReleaseHeaders } from "@/services/releaseHeaders.js";
export const RELEASE_RUNTIME_GLOBAL_KEY = "__TRUCKWASH_RELEASE_RUNTIME__"; export const RELEASE_RUNTIME_GLOBAL_KEY = "__TRUCKWASH_RELEASE_RUNTIME__";
@@ -80,26 +80,18 @@ export const resolveReleaseSourceMode = (
return readReleaseSourceOverride() || RELEASE_SOURCE_MODES.AUTO; return readReleaseSourceOverride() || RELEASE_SOURCE_MODES.AUTO;
}; };
const releaseChannelGatewayApiBaseUrl = (channelSlug, gatewayBaseUrl = RELEASE_PUBLIC_GATEWAY_API_URL) => { const selectedReleaseChannelSlug = (selectedChannel) => {
const slug = normalizeReleaseChannelSlug(channelSlug); if (selectedChannel !== undefined && selectedChannel !== null) {
if (!slug) { return normalizeReleaseChannelSlug(selectedChannel);
return "";
} }
const routeSlug = slug === "stable" ? "master" : slug; return readSelectedReleaseChannel();
const baseUrl = normalizeBaseUrl(gatewayBaseUrl);
if (!baseUrl || !/^https?:\/\//i.test(baseUrl)) {
return "";
}
return new URL(`${routeSlug}/api/`, `${baseUrl}/`).href.replace(/\/+$/, "");
}; };
const buildRuntimeHeaders = () => { const buildRuntimeHeaders = (selectedChannel) => {
const storage = browserStorage(); const storage = browserStorage();
const headers = { const headers = {
Accept: "application/json", Accept: "application/json",
...buildReleaseHeaders({ channelSlug: readSelectedReleaseChannel() }), ...buildReleaseHeaders({ channelSlug: selectedReleaseChannelSlug(selectedChannel) }),
}; };
const token = storage?.getItem("token"); const token = storage?.getItem("token");
if (token) { if (token) {
@@ -112,10 +104,9 @@ const buildRuntimeHeaders = () => {
return headers; return headers;
}; };
export const runtimeApiUrl = (apiBaseUrl = API_URL, gatewayBaseUrl = RELEASE_PUBLIC_GATEWAY_API_URL) => { export const runtimeApiUrl = (apiBaseUrl = RELEASE_MANAGER_CONTROL_API_URL || API_URL, options = {}) => {
const selectedChannel = readSelectedReleaseChannel(); const selectedChannel = selectedReleaseChannelSlug(options?.selectedChannel);
const selectedChannelApiBaseUrl = releaseChannelGatewayApiBaseUrl(selectedChannel, gatewayBaseUrl); const runtimeBaseUrl = resolveRuntimeBaseUrl(apiBaseUrl || API_URL);
const runtimeBaseUrl = selectedChannelApiBaseUrl || resolveRuntimeBaseUrl(apiBaseUrl);
const url = new URL("release/runtime", `${runtimeBaseUrl}/`); const url = new URL("release/runtime", `${runtimeBaseUrl}/`);
if (selectedChannel) { if (selectedChannel) {
url.searchParams.set("release_channel", selectedChannel); url.searchParams.set("release_channel", selectedChannel);
@@ -140,16 +131,16 @@ const parseJsonResponse = async (response, label) => {
export const fetchReleaseRuntime = async ({ export const fetchReleaseRuntime = async ({
fetchFn = globalThis.fetch, fetchFn = globalThis.fetch,
apiBaseUrl = API_URL, apiBaseUrl = RELEASE_MANAGER_CONTROL_API_URL || API_URL,
gatewayBaseUrl = RELEASE_PUBLIC_GATEWAY_API_URL, selectedChannel,
} = {}) => { } = {}) => {
if (typeof fetchFn !== "function") { if (typeof fetchFn !== "function") {
return null; return null;
} }
const response = await fetchFn(runtimeApiUrl(apiBaseUrl, gatewayBaseUrl), { const response = await fetchFn(runtimeApiUrl(apiBaseUrl, { selectedChannel }), {
method: "GET", method: "GET",
headers: buildRuntimeHeaders(), headers: buildRuntimeHeaders(selectedChannel),
credentials: "omit", credentials: "omit",
cache: "no-store", cache: "no-store",
}); });
+53
View File
@@ -0,0 +1,53 @@
const isPlainObject = (value) => Boolean(value && typeof value === "object" && !Array.isArray(value));
const objectOrEmpty = (value) => (isPlainObject(value) ? value : {});
const arrayOrEmpty = (value) => (Array.isArray(value) ? value : []);
const normalizeEconomicCustomer = (value) => {
if (Array.isArray(value)) {
return value.find(isPlainObject) || null;
}
if (!isPlainObject(value) || Object.keys(value).length === 0) {
return null;
}
return value;
};
export const normalizeSessionPayload = (payload = {}) => {
const data = objectOrEmpty(payload);
const phone = objectOrEmpty(data.phone);
const notifications = objectOrEmpty(data.notifications);
const runtimeConfig = objectOrEmpty(data.runtime_config);
const economicConfig = objectOrEmpty(runtimeConfig.economic);
return {
id: data.id ?? null,
customer_number: data.customer_number ?? null,
group_id: data.group_id ?? null,
email: data.email ?? null,
phone: {
number: phone.number ?? null,
country_code: phone.country_code ?? null,
},
notifications: {
wash_certificate_email: notifications.wash_certificate_email ?? null,
email_notifications_enabled: notifications.email_notifications_enabled ?? null,
sms_notifications_enabled: notifications.sms_notifications_enabled ?? null,
},
created_at: data.created_at ?? null,
updated_at: data.updated_at ?? null,
display_name: data.display_name ?? null,
permissions: arrayOrEmpty(data.permissions),
economic_customer: normalizeEconomicCustomer(data.economic_customer),
runtime_config: {
economic: {
transaction_draft_customer_number: economicConfig.transaction_draft_customer_number ?? null,
default_distribution_department_id: economicConfig.default_distribution_department_id ?? null,
},
release: objectOrEmpty(runtimeConfig.release),
},
};
};
@@ -442,16 +442,28 @@ const selectedServiceSet = computed(
() => serviceSets.value.find((set) => Number(set.id) === Number(bundleForm.source_service_set_id)) || null () => serviceSets.value.find((set) => Number(set.id) === Number(bundleForm.source_service_set_id)) || null
); );
const isIsolatedStackMode = computed(() => bundleForm.dataset_mode === "isolated_stack"); const isIsolatedStackMode = computed(() => bundleForm.dataset_mode === "isolated_stack");
const frontendBundleSourceTarget = computed( const selectedBundleChannelSlug = computed(() => releaseChannelSlug(selectedBundleChannel()));
() => selectedServiceSet.value?.targets?.frontend || targets.value.find((target) => target.app === "frontend") || null const isBetaBundleChannel = computed(() => selectedBundleChannelSlug.value === "beta");
const betaProductionDataSourceServiceSet = computed(() => productionDataSourceServiceSet());
const frontendBundleSourceTarget = computed(() =>
isBetaBundleChannel.value
? targetForBundleChannelApp("frontend") || targets.value.find((target) => target.app === "frontend") || null
: selectedServiceSet.value?.targets?.frontend || targets.value.find((target) => target.app === "frontend") || null
); );
const apiBundleSourceTarget = computed( const apiBundleSourceTarget = computed(() =>
() => selectedServiceSet.value?.targets?.api || targets.value.find((target) => target.app === "api") || null isBetaBundleChannel.value
? targetForBundleChannelApp("api") || targets.value.find((target) => target.app === "api") || null
: selectedServiceSet.value?.targets?.api || targets.value.find((target) => target.app === "api") || null
); );
const frontendBundleTarget = computed(() => (isIsolatedStackMode.value ? null : frontendBundleSourceTarget.value)); const frontendBundleTarget = computed(() => (isIsolatedStackMode.value ? null : frontendBundleSourceTarget.value));
const apiBundleTarget = computed(() => (isIsolatedStackMode.value ? null : apiBundleSourceTarget.value)); const apiBundleTarget = computed(() => (isIsolatedStackMode.value ? null : apiBundleSourceTarget.value));
const datasetModeMessage = computed(() => const datasetModeMessage = computed(() =>
isIsolatedStackMode.value isBetaBundleChannel.value
? trFallback(
"bundles.beta_production_data_message",
"Beta is locked to production-shared data. Frontend and API targets stay beta-specific."
)
: isIsolatedStackMode.value
? trFallback( ? trFallback(
"bundles.isolated_stack_message", "bundles.isolated_stack_message",
"Creates new Coolify frontend, API, database, Redis, and MinIO services without attaching production data." "Creates new Coolify frontend, API, database, Redis, and MinIO services without attaching production data."
@@ -461,65 +473,90 @@ const datasetModeMessage = computed(() =>
"Reuse services, clone them through replicas, or start isolated empty data services." "Reuse services, clone them through replicas, or start isolated empty data services."
) )
); );
const datasetModeOptions = computed(() => [ const datasetModeOptions = computed(() => {
{ const betaLocked = isBetaBundleChannel.value;
value: "attach_existing", return [
icon: "recycle", {
title: trFallback("bundles.modes.attach_existing", "Reuse existing services"), value: "attach_existing",
description: trFallback( icon: "recycle",
"bundles.modes.attach_existing_description", title: betaLocked
"Attach the release to the selected existing frontend, API, MariaDB, Redis, and MinIO services." ? trFallback("bundles.modes.beta_production_shared", "Use production data")
), : trFallback("bundles.modes.attach_existing", "Reuse existing services"),
tag: trFallback("bundles.preview.production_data_attached", "production data attached"), description: betaLocked
type: "is-warning is-light", ? trFallback(
}, "bundles.modes.beta_production_shared_description",
{ "Create a beta service set with beta frontend/API targets and production_shared MariaDB, Redis, and MinIO."
value: "clone_existing", )
icon: "copy", : trFallback(
title: trFallback("bundles.modes.clone_existing", "Clone existing services"), "bundles.modes.attach_existing_description",
description: trFallback( "Attach the release to the selected existing frontend, API, MariaDB, Redis, and MinIO services."
"bundles.modes.clone_existing_description", ),
"Create a new service set that plans replica clones from the selected source data services." tag: "production_shared",
), type: "is-warning is-light",
tag: trFallback("bundles.preview.clone_plan", "clone plan"), disabled: false,
type: "is-info is-light", },
}, {
{ value: "clone_existing",
value: "fresh_empty", icon: "copy",
icon: "database", title: trFallback("bundles.modes.clone_existing", "Clone existing services"),
title: trFallback("bundles.modes.fresh_empty", "Create fresh empty datasets"), description: trFallback(
description: trFallback( "bundles.modes.clone_existing_description",
"bundles.modes.fresh_empty_description", "Create a new service set that plans replica clones from the selected source data services."
"Create a new service set with empty or unattached MariaDB, Redis, and MinIO services." ),
), tag: betaLocked ? trFallback("bundles.modes.beta_locked", "locked for beta") : trFallback("bundles.preview.clone_plan", "clone plan"),
tag: trFallback("bundles.preview.no_production_data", "no production data"), type: betaLocked ? "is-light" : "is-info is-light",
type: "is-success is-light", disabled: betaLocked,
}, },
{ {
value: "isolated_stack", value: "fresh_empty",
icon: "shield-alt", icon: "database",
title: trFallback("bundles.modes.isolated_stack", "Create isolated stack (safe)"), title: trFallback("bundles.modes.fresh_empty", "Create fresh empty datasets"),
description: trFallback( description: trFallback(
"bundles.modes.isolated_stack_description", "bundles.modes.fresh_empty_description",
"Create new Coolify frontend/API services plus isolated MariaDB, Redis, and MinIO services." "Create a new service set with empty or unattached MariaDB, Redis, and MinIO services."
), ),
tag: trFallback("bundles.preview.new_coolify_services", "new Coolify services"), tag: betaLocked ? trFallback("bundles.modes.beta_locked", "locked for beta") : trFallback("bundles.preview.no_production_data", "no production data"),
type: "is-success is-light", type: betaLocked ? "is-light" : "is-success is-light",
}, disabled: betaLocked,
]); },
{
value: "isolated_stack",
icon: "shield-alt",
title: trFallback("bundles.modes.isolated_stack", "Create isolated stack (safe)"),
description: trFallback(
"bundles.modes.isolated_stack_description",
"Create new Coolify frontend/API services plus isolated MariaDB, Redis, and MinIO services."
),
tag: betaLocked ? trFallback("bundles.modes.beta_locked", "locked for beta") : trFallback("bundles.preview.new_coolify_services", "new Coolify services"),
type: betaLocked ? "is-light" : "is-success is-light",
disabled: betaLocked,
},
];
});
const selectedDatasetModeOption = computed( const selectedDatasetModeOption = computed(
() => datasetModeOptions.value.find((option) => option.value === bundleForm.dataset_mode) || datasetModeOptions.value[0] () => datasetModeOptions.value.find((option) => option.value === bundleForm.dataset_mode) || datasetModeOptions.value[0]
); );
const bundleApplySteps = computed(() => { const bundleApplySteps = computed(() => {
const mode = bundleForm.dataset_mode; const mode = bundleForm.dataset_mode;
const sourceName = selectedServiceSet.value?.name || trFallback("bundles.use_current_targets", "current integration targets"); const betaDataOnly = isBetaBundleChannel.value;
const sourceName =
(betaDataOnly ? betaProductionDataSourceServiceSet.value?.name : selectedServiceSet.value?.name) ||
trFallback("bundles.use_current_targets", "current integration targets");
const serviceSetName = const serviceSetName =
mode === "attach_existing" && selectedServiceSet.value betaDataOnly
? bundleForm.service_set_name || "beta-production-data"
: mode === "attach_existing" && selectedServiceSet.value
? sourceName ? sourceName
: bundleForm.service_set_name || `${selectedBundleChannel()?.slug || "release"} service set`; : bundleForm.service_set_name || `${selectedBundleChannel()?.slug || "release"} service set`;
const serviceSetStep = const serviceSetStep =
mode === "attach_existing" betaDataOnly
? trFallback(
"bundles.preview.step_beta_production_data_service_set",
`Create service set ${serviceSetName} with beta frontend/API targets and production_shared data from ${sourceName}.`,
{ name: serviceSetName, source: sourceName }
)
: mode === "attach_existing"
? trFallback( ? trFallback(
"bundles.preview.step_attach_service_set", "bundles.preview.step_attach_service_set",
`Attach the release bundle to the selected service set: ${sourceName}.`, `Attach the release bundle to the selected service set: ${sourceName}.`,
@@ -571,6 +608,15 @@ const bundleApplyPreviewRows = computed(() => [
]); ]);
const bundleApplyWarnings = computed(() => { const bundleApplyWarnings = computed(() => {
const warnings = []; const warnings = [];
if (isBetaBundleChannel.value) {
warnings.push({
type: "is-danger is-light",
text: trFallback(
"bundles.preview.warning_beta_production_data",
"Beta uses production data: MariaDB, Redis, and MinIO resolve to production_shared while frontend/API remain beta targets."
),
});
}
if (bundleForm.dataset_mode === "attach_existing") { if (bundleForm.dataset_mode === "attach_existing") {
warnings.push({ warnings.push({
type: "is-warning", type: "is-warning",
@@ -632,16 +678,22 @@ const bundleReviewStack = computed(() => [
...["database", "redis", "minio"].map((kind) => { ...["database", "redis", "minio"].map((kind) => {
const service = isIsolatedStackMode.value const service = isIsolatedStackMode.value
? null ? null
: isBetaBundleChannel.value
? serviceSetDataService(kind)
: selectedServiceSet.value?.data_services?.[kind] || selectedServiceSet.value?.stack?.[kind] || null; : selectedServiceSet.value?.data_services?.[kind] || selectedServiceSet.value?.stack?.[kind] || null;
return { return {
key: kind, key: kind,
label: trFallback(`values.stack.${kind}`, kind.charAt(0).toUpperCase() + kind.slice(1)), label: trFallback(`values.stack.${kind}`, kind.charAt(0).toUpperCase() + kind.slice(1)),
value: isIsolatedStackMode.value value: isIsolatedStackMode.value
? trFallback("bundles.new_empty_data_service", "New empty service") ? trFallback("bundles.new_empty_data_service", "New empty service")
: isBetaBundleChannel.value
? "production_shared"
: service?.label || service?.resource_name || trFallback("bundles.unassigned_data_service", "Not assigned yet"), : service?.label || service?.resource_name || trFallback("bundles.unassigned_data_service", "Not assigned yet"),
detail: service?.replication?.label || service?.replication?.host || service?.availability_state || "", detail: service?.replication?.label || service?.replication?.host || service?.availability_state || "",
status: isIsolatedStackMode.value status: isIsolatedStackMode.value
? trFallback("bundles.production_data_not_attached", "production data not attached") ? trFallback("bundles.production_data_not_attached", "production data not attached")
: isBetaBundleChannel.value
? trFallback("bundles.preview.beta_uses_production_data", "Beta uses production data")
: service?.replication?.status || : service?.replication?.status ||
service?.deployment_status || service?.deployment_status ||
trFallback("bundles.needs_configuration", "needs configuration"), trFallback("bundles.needs_configuration", "needs configuration"),
@@ -2013,32 +2065,55 @@ function deploymentPayloadFromTarget(target) {
function applyBundleDefaults() { function applyBundleDefaults() {
if (!isIsolatedStackMode.value && !bundleForm.source_service_set_id && serviceSets.value[0]) { if (!isIsolatedStackMode.value && !bundleForm.source_service_set_id && serviceSets.value[0]) {
bundleForm.source_service_set_id = serviceSets.value[0].id; bundleForm.source_service_set_id = isBetaBundleChannel.value
? betaProductionDataSourceServiceSet.value?.id || serviceSets.value[0].id
: serviceSets.value[0].id;
} }
const frontendDefaultTarget = isBetaBundleChannel.value
? targetForBundleChannelApp("frontend") || selectedServiceSet.value?.targets?.frontend
: selectedServiceSet.value?.targets?.frontend;
const apiDefaultTarget = isBetaBundleChannel.value
? targetForBundleChannelApp("api") || selectedServiceSet.value?.targets?.api
: selectedServiceSet.value?.targets?.api;
if (!bundleForm.channel_id) { if (!bundleForm.channel_id) {
bundleForm.channel_id = selectedServiceSet.value?.channel_id || defaultChannel.value?.id || null; bundleForm.channel_id = selectedServiceSet.value?.channel_id || defaultChannel.value?.id || null;
} }
if (!bundleForm.frontend_repository) { if (!bundleForm.frontend_repository) {
bundleForm.frontend_repository = bundleForm.frontend_repository =
selectedServiceSet.value?.targets?.frontend?.repository || frontendDefaultTarget?.repository ||
targets.value.find((target) => target.app === "frontend")?.repository || targets.value.find((target) => target.app === "frontend")?.repository ||
firstAvailable(repositorySuggestions.value.filter((repository) => /front|vue|web/i.test(repository))); firstAvailable(repositorySuggestions.value.filter((repository) => /front|vue|web/i.test(repository)));
} }
if (!bundleForm.api_repository) { if (!bundleForm.api_repository) {
bundleForm.api_repository = bundleForm.api_repository =
selectedServiceSet.value?.targets?.api?.repository || apiDefaultTarget?.repository ||
targets.value.find((target) => target.app === "api")?.repository || targets.value.find((target) => target.app === "api")?.repository ||
firstAvailable(repositorySuggestions.value.filter((repository) => /api|backend|php/i.test(repository))); firstAvailable(repositorySuggestions.value.filter((repository) => /api|backend|php/i.test(repository)));
} }
if (!bundleForm.frontend_branch) { if (!bundleForm.frontend_branch) {
bundleForm.frontend_branch = selectedServiceSet.value?.targets?.frontend?.branch || DEFAULT_RELEASE_BRANCH; bundleForm.frontend_branch = frontendDefaultTarget?.branch || DEFAULT_RELEASE_BRANCH;
} }
if (!bundleForm.api_branch) { if (!bundleForm.api_branch) {
bundleForm.api_branch = selectedServiceSet.value?.targets?.api?.branch || DEFAULT_RELEASE_BRANCH; bundleForm.api_branch = apiDefaultTarget?.branch || DEFAULT_RELEASE_BRANCH;
} }
applyBundleVersionLabelDefault(); applyBundleVersionLabelDefault();
} }
function applyBetaBundleCodeTargetDefaults() {
if (!isBetaBundleChannel.value) {
return;
}
for (const app of ["frontend", "api"]) {
const target = targetForBundleChannelApp(app);
if (target?.repository) {
bundleForm[`${app}_repository`] = target.repository;
}
if (target?.branch) {
bundleForm[`${app}_branch`] = target.branch;
}
}
}
function bundleVersionLabelForChannel(channelId = bundleForm.channel_id) { function bundleVersionLabelForChannel(channelId = bundleForm.channel_id) {
const channelSlug = const channelSlug =
channels.value.find((channel) => Number(channel.id) === Number(channelId))?.slug || channels.value.find((channel) => Number(channel.id) === Number(channelId))?.slug ||
@@ -2333,6 +2408,9 @@ function serviceSetStackItem(set, item) {
} }
function selectDatasetMode(mode) { function selectDatasetMode(mode) {
if (isBetaBundleChannel.value && mode !== "attach_existing") {
return;
}
bundleForm.dataset_mode = mode; bundleForm.dataset_mode = mode;
} }
@@ -2468,7 +2546,7 @@ function targetLocation(target) {
} }
function serviceSetDataService(kind) { function serviceSetDataService(kind) {
const set = selectedServiceSet.value; const set = isBetaBundleChannel.value ? betaProductionDataSourceServiceSet.value : selectedServiceSet.value;
return set?.data_services?.[kind] || set?.stack?.[kind] || null; return set?.data_services?.[kind] || set?.stack?.[kind] || null;
} }
@@ -2564,6 +2642,20 @@ function previewDataRow(kind) {
const endpoint = dataServiceEndpoint(service); const endpoint = dataServiceEndpoint(service);
if (bundleForm.dataset_mode === "attach_existing") { if (bundleForm.dataset_mode === "attach_existing") {
if (isBetaBundleChannel.value) {
return {
key: kind,
label,
action: trFallback("bundles.preview.action_beta_production_data", "Use production_shared data"),
type: "is-danger is-light",
source: serviceName,
target: "production_shared",
endpoint: endpoint || "production_shared",
endpointPending: false,
location: dataServiceLocation(service) || trFallback("bundles.preview.production_shared_location", "Stable/Master production data services"),
dataPolicy: trFallback("bundles.preview.beta_uses_production_data", "Beta uses production data"),
};
}
return { return {
key: kind, key: kind,
label, label,
@@ -2732,6 +2824,38 @@ function selectedBundleChannel() {
); );
} }
function releaseChannelSlug(entry) {
return String(entry?.slug || entry?.channel_slug || "")
.trim()
.toLowerCase();
}
function isProductionDataSourceChannel(entry) {
return ["stable", "master", "production", "prod"].includes(releaseChannelSlug(entry));
}
function productionDataSourceServiceSet() {
if (selectedServiceSet.value && isProductionDataSourceChannel(selectedServiceSet.value)) {
return selectedServiceSet.value;
}
return (
serviceSets.value.find((set) => isProductionDataSourceChannel(set) && set.active) ||
serviceSets.value.find((set) => isProductionDataSourceChannel(set) && ["ready", "active"].includes(String(set.status || "").toLowerCase())) ||
serviceSets.value.find((set) => isProductionDataSourceChannel(set)) ||
null
);
}
function targetForBundleChannelApp(app) {
const channelId = Number(bundleForm.channel_id || selectedBundleChannel()?.id || 0);
const channelSlug = releaseChannelSlug(selectedBundleChannel());
return (
targets.value.find((target) => target.app === app && channelId && Number(target.channel_id) === channelId) ||
targets.value.find((target) => target.app === app && channelSlug && releaseChannelSlug(target) === channelSlug) ||
null
);
}
function bundleChannelId() { function bundleChannelId() {
return bundleForm.channel_id || selectedServiceSet.value?.channel_id || selectedBundleChannel()?.id || null; return bundleForm.channel_id || selectedServiceSet.value?.channel_id || selectedBundleChannel()?.id || null;
} }
@@ -3008,7 +3132,8 @@ function nextReleaseFlowStep() {
async function serviceSetIdForBundle() { async function serviceSetIdForBundle() {
const sourceId = isIsolatedStackMode.value ? null : Number(bundleForm.source_service_set_id || 0) || null; const sourceId = isIsolatedStackMode.value ? null : Number(bundleForm.source_service_set_id || 0) || null;
if (bundleForm.dataset_mode === "attach_existing" && sourceId) { const betaDataOnly = isBetaBundleChannel.value;
if (bundleForm.dataset_mode === "attach_existing" && sourceId && !betaDataOnly) {
return sourceId; return sourceId;
} }
@@ -3032,23 +3157,36 @@ async function serviceSetIdForBundle() {
} }
: null; : null;
const serviceSetPayload = { const serviceSetPayload = {
mode: bundleForm.dataset_mode, mode: betaDataOnly ? "attach_existing" : bundleForm.dataset_mode,
source_service_set_id: sourceId, source_service_set_id: betaDataOnly ? null : sourceId,
data_source_service_set_id: betaDataOnly
? Number(betaProductionDataSourceServiceSet.value?.id || sourceId || 0) || null
: null,
channel_id: bundleChannelId(), channel_id: bundleChannelId(),
name: name:
bundleForm.service_set_name || bundleForm.service_set_name ||
`${selectedBundleChannel()?.slug || "release"} ${ (betaDataOnly
bundleForm.dataset_mode === "isolated_stack" ? "beta-production-data"
? "isolated stack" : `${selectedBundleChannel()?.slug || "release"} ${
: bundleForm.dataset_mode === "fresh_empty" bundleForm.dataset_mode === "isolated_stack"
? "fresh data" ? "isolated stack"
: "service set" : bundleForm.dataset_mode === "fresh_empty"
}`, ? "fresh data"
: "service set"
}`),
frontend_target_id: isolatedTargets?.frontend?.id || frontendBundleTarget.value?.id || null, frontend_target_id: isolatedTargets?.frontend?.id || frontendBundleTarget.value?.id || null,
api_target_id: isolatedTargets?.api?.id || apiBundleTarget.value?.id || null, api_target_id: isolatedTargets?.api?.id || apiBundleTarget.value?.id || null,
create_data_targets: isIsolatedStackMode.value, create_data_targets: isIsolatedStackMode.value,
deploy_data_targets: isIsolatedStackMode.value, deploy_data_targets: isIsolatedStackMode.value,
metadata: isIsolatedStackMode.value metadata: betaDataOnly
? {
data_policy: "production_shared",
data_service_mode: "production_shared",
data_source_channel_slug: releaseChannelSlug(betaProductionDataSourceServiceSet.value) || "stable",
production_data_attached: true,
production_code_targets_attached: false,
}
: isIsolatedStackMode.value
? { ? {
isolated_stack: true, isolated_stack: true,
production_data_attached: false, production_data_attached: false,
@@ -4054,6 +4192,13 @@ watch(
watch( watch(
() => bundleForm.source_service_set_id, () => bundleForm.source_service_set_id,
() => { () => {
if (isBetaBundleChannel.value && selectedServiceSet.value && !isProductionDataSourceChannel(selectedServiceSet.value)) {
const dataSource = betaProductionDataSourceServiceSet.value;
if (dataSource?.id) {
bundleForm.source_service_set_id = dataSource.id;
return;
}
}
applyBundleDefaults(); applyBundleDefaults();
} }
); );
@@ -4061,6 +4206,18 @@ watch(
watch( watch(
() => bundleForm.channel_id, () => bundleForm.channel_id,
() => { () => {
if (isBetaBundleChannel.value) {
bundleForm.dataset_mode = "attach_existing";
const dataSource = betaProductionDataSourceServiceSet.value;
if (dataSource?.id && (!selectedServiceSet.value || !isProductionDataSourceChannel(selectedServiceSet.value))) {
bundleForm.source_service_set_id = dataSource.id;
}
if (!bundleForm.service_set_name) {
bundleForm.service_set_name = "beta-production-data";
}
applyBetaBundleCodeTargetDefaults();
applyBundleDefaults();
}
applyBundleVersionLabelDefault(); applyBundleVersionLabelDefault();
} }
); );
@@ -4068,6 +4225,10 @@ watch(
watch( watch(
() => bundleForm.dataset_mode, () => bundleForm.dataset_mode,
(mode) => { (mode) => {
if (isBetaBundleChannel.value && mode !== "attach_existing") {
bundleForm.dataset_mode = "attach_existing";
return;
}
if (mode === "isolated_stack") { if (mode === "isolated_stack") {
bundleForm.source_service_set_id = null; bundleForm.source_service_set_id = null;
} }
@@ -5428,8 +5589,9 @@ onMounted(async () => {
:key="option.value" :key="option.value"
type="button" type="button"
class="release-dataset-mode-card" class="release-dataset-mode-card"
:class="{ 'is-selected': bundleForm.dataset_mode === option.value }" :class="{ 'is-selected': bundleForm.dataset_mode === option.value, 'is-disabled': option.disabled }"
:data-testid="`release-dataset-mode-${option.value}`" :data-testid="`release-dataset-mode-${option.value}`"
:disabled="option.disabled"
@click="selectDatasetMode(option.value)" @click="selectDatasetMode(option.value)"
> >
<span class="release-dataset-mode-card__icon"> <span class="release-dataset-mode-card__icon">
@@ -5448,7 +5610,12 @@ onMounted(async () => {
data-testid="release-bundle-dataset-mode" data-testid="release-bundle-dataset-mode"
:aria-label="trFallback('bundles.dataset_mode', 'Dataset mode')" :aria-label="trFallback('bundles.dataset_mode', 'Dataset mode')"
> >
<option v-for="option in datasetModeOptions" :key="`native-${option.value}`" :value="option.value"> <option
v-for="option in datasetModeOptions"
:key="`native-${option.value}`"
:value="option.value"
:disabled="option.disabled"
>
{{ option.title }} {{ option.title }}
</option> </option>
</select> </select>
@@ -8462,6 +8629,18 @@ onMounted(async () => {
background: #fbfcfe; background: #fbfcfe;
} }
.release-dataset-mode-card:disabled,
.release-dataset-mode-card.is-disabled {
cursor: not-allowed;
opacity: 0.55;
}
.release-dataset-mode-card:disabled:hover,
.release-dataset-mode-card.is-disabled:hover {
background: #ffffff;
border-color: #d8dee8;
}
.release-dataset-mode-card.is-selected { .release-dataset-mode-card.is-selected {
border-color: #3273dc; border-color: #3273dc;
box-shadow: 0 0 0 1px rgba(50, 115, 220, 0.28); box-shadow: 0 0 0 1px rgba(50, 115, 220, 0.28);
+7 -2
View File
@@ -49,9 +49,10 @@ test("non-default release frontend loads from api-v2.truckwash.io without redire
await page.addInitScript(() => { await page.addInitScript(() => {
window.localStorage.setItem("release_channel_selected_slug", "canary"); window.localStorage.setItem("release_channel_selected_slug", "canary");
window.localStorage.setItem("release_source_override", "deployment");
}); });
await page.route("https://api-v2.truckwash.io/canary/api/release/runtime**", async (route) => { await page.route("**/release/runtime**", async (route) => {
runtimeRequests.push(route.request().url()); runtimeRequests.push(route.request().url());
await route.fulfill(json({ data: runtime })); await route.fulfill(json({ data: runtime }));
}); });
@@ -95,7 +96,11 @@ test("non-default release frontend loads from api-v2.truckwash.io without redire
const currentOrigin = await page.evaluate(() => window.location.origin); const currentOrigin = await page.evaluate(() => window.location.origin);
expect(page.url()).toContain("/shared/passkey-safe-link"); expect(page.url()).toContain("/shared/passkey-safe-link");
expect(page.url()).not.toContain("api-v2.truckwash.io"); expect(page.url()).not.toContain("api-v2.truckwash.io");
expect(runtimeRequests).toEqual(["https://api-v2.truckwash.io/canary/api/release/runtime?release_channel=canary"]); expect(runtimeRequests).toHaveLength(1);
const runtimeRequestUrl = new URL(runtimeRequests[0]);
expect(["/api/release/runtime", "/master/api/release/runtime"]).toContain(runtimeRequestUrl.pathname);
expect(runtimeRequestUrl.pathname).not.toBe("/canary/api/release/runtime");
expect(runtimeRequestUrl.searchParams.get("release_channel")).toBe("canary");
expect(releaseEntryRequests).toEqual(["https://api-v2.truckwash.io/canary/frontend/release-entry.json"]); expect(releaseEntryRequests).toEqual(["https://api-v2.truckwash.io/canary/frontend/release-entry.json"]);
await expect.poll(() => releaseApiRequests.length).toBe(1); await expect.poll(() => releaseApiRequests.length).toBe(1);
expect(releaseApiRequests[0]).toBe("https://api-v2.truckwash.io/canary/api/ping"); expect(releaseApiRequests[0]).toBe("https://api-v2.truckwash.io/canary/api/ping");
@@ -1,6 +1,8 @@
import { expect, test } from "@playwright/test"; import { expect, test } from "@playwright/test";
import { mockApi, seedAuthenticatedState } from "./support/network.js"; import { mockApi, seedAuthenticatedState } from "./support/network.js";
test.describe.configure({ mode: "serial" });
const json = (body, status = 200) => ({ const json = (body, status = 200) => ({
status, status,
contentType: "application/json", contentType: "application/json",
@@ -176,6 +178,121 @@ const runtimeWithFailingBetaSwitch = () => ({
], ],
}); });
const runtimeWithReadyBetaSelected = () => {
const stableRuntime = runtimeWithFailingBetaSwitch();
const betaChannel = stableRuntime.available_channels.find((option) => option.channel.slug === "beta").channel;
return {
...stableRuntime,
generated_at: "2026-05-19T09:11:00.000Z",
trace_id: "trace-beta-channel",
channel: betaChannel,
versions: {
frontend: { version_label: "frontend-beta", commit_sha: "be7afe" },
api: { version_label: "api-beta", commit_sha: "ba5eba11" },
bundle_id: 32,
},
frontend_base_url: "https://api-v2.truckwash.io/beta/frontend",
api_base_url: "https://api-v2.truckwash.io/beta/api",
urls: {
frontend_base_url: "https://api-v2.truckwash.io/beta/frontend",
api_base_url: "https://api-v2.truckwash.io/beta/api",
},
availability: {
configured: true,
missing: [],
status: "ready",
},
available_channels: stableRuntime.available_channels,
};
};
const internalMissingFrontendEntryRuntime = () => ({
generated_at: "2026-05-20T17:50:00.000Z",
trace_id: "trace-internal-missing-frontend-entry",
channel: {
id: 4,
slug: "internal",
name: "Intern",
description: "Internal staff and superuser validation channel.",
enabled: true,
default_channel: false,
},
versions: {
frontend: null,
api: {
version_label: "api-internal",
commit_sha: "e40cf6b6ac31",
deployed_at: "2026-05-20T17:50:00.000Z",
},
bundle_id: null,
},
frontend_base_url: null,
api_base_url: "https://api-v2.truckwash.io/internal/api",
urls: {
frontend_base_url: null,
api_base_url: "https://api-v2.truckwash.io/internal/api",
},
availability: {
configured: false,
explicit: true,
missing: ["frontend_entry"],
status: "unconfigured",
},
available_channels: [
{
channel: {
id: 1,
slug: "stable",
name: "Stable",
description: "Standard production channel.",
enabled: true,
default_channel: true,
},
versions: {
frontend: { version_label: "frontend-stable", commit_sha: "abc123" },
api: { version_label: "api-stable", commit_sha: "def456" },
bundle_id: 31,
},
availability: {
configured: true,
missing: [],
status: "ready",
},
},
{
channel: {
id: 4,
slug: "internal",
name: "Intern",
description: "Internal staff and superuser validation channel.",
enabled: true,
default_channel: false,
},
versions: {
frontend: null,
api: {
version_label: "api-internal",
commit_sha: "e40cf6b6ac31",
deployed_at: "2026-05-20T17:50:00.000Z",
},
bundle_id: null,
},
availability: {
configured: false,
explicit: true,
missing: ["frontend_entry"],
status: "unconfigured",
},
},
],
capture_policy: {
enabled: false,
capture_level: "metadata",
all_failure_metadata: true,
retention_days: 14,
},
});
async function boot(page, runtime = unavailableRuntime, locale = "en") { async function boot(page, runtime = unavailableRuntime, locale = "en") {
await page.addInitScript((selectedLocale) => { await page.addInitScript((selectedLocale) => {
window.localStorage.setItem("locale", selectedLocale); window.localStorage.setItem("locale", selectedLocale);
@@ -313,6 +430,84 @@ test("selected channel auth session 404 shows the release channel guard", async
expect(channelApiRequests.every((url) => url.startsWith("https://api-v2.truckwash.io/internal/api/"))).toBe(true); expect(channelApiRequests.every((url) => url.startsWith("https://api-v2.truckwash.io/internal/api/"))).toBe(true);
}); });
test("login assigned to an unconfigured internal channel shows guard actions without the user-data modal", async ({
page,
}) => {
const runtime = internalMissingFrontendEntryRuntime();
let releaseRuntimeResponse = runtimeWithFailingBetaSwitch();
const sessionRequests = [];
await page.addInitScript(() => {
window.localStorage.setItem("locale", "en");
window.localStorage.removeItem("token");
window.localStorage.removeItem("is_subuser");
window.localStorage.removeItem("selected_customer_number");
window.localStorage.removeItem("release_channel_selected_slug");
window.localStorage.removeItem("release_channel_unavailable_ignore_until");
window.localStorage.setItem("release_source_override", "deployment");
});
await mockApi(page, {
authenticated: true,
loginToken: "release-login-token",
permissions: ["user"],
sessionData: {
phone: undefined,
notifications: undefined,
permissions: undefined,
economic_customer: undefined,
runtime_config: {
release: runtime,
},
},
});
await page.route("**/release/runtime**", async (route) => {
await route.fulfill(json({ data: releaseRuntimeResponse }));
});
await page.route("https://api-v2.truckwash.io/internal/api/**", async (route) => {
await route.fulfill(json({ data: [] }));
});
page.on("request", (request) => {
if (request.method() === "GET" && request.url().includes("/auth/session")) {
sessionRequests.push(request.url());
}
});
await page.goto("/login?redirect=%2Fuser", { waitUntil: "domcontentloaded" });
await page.getByTestId("login-customer-number").fill("12345");
await page.getByTestId("login-password").fill("correct horse battery staple");
releaseRuntimeResponse = runtime;
await page.getByTestId("login-submit").click();
const guard = page.getByTestId("release-channel-unavailable-page");
await expect(guard).toBeVisible({ timeout: GUARD_TIMEOUT_MS });
await expect(page).toHaveURL(/\/user/);
await expect(guard).toContainText("Release channel is not ready");
await expect(guard).toContainText("Intern");
await expect(page.getByTestId("release-channel-missing")).toContainText("Frontend entry");
await expect(page.getByTestId("release-channel-option-stable")).toBeVisible();
await expect(page.getByTestId("release-channel-ignore")).toBeVisible();
await expect(page.getByTestId("release-channel-check-again")).toBeVisible();
await expect(guard.getByRole("button", { name: /log\s*out|logout/i })).toBeVisible();
await expect(page.locator(".swal2-popup")).toHaveCount(0);
await page.getByTestId("release-channel-ignore").click();
await expect(guard).toHaveCount(0);
await expect(page).toHaveURL(/\/user/);
await expect(page.locator(".swal2-popup")).toHaveCount(0);
await page.evaluate(() => window.localStorage.removeItem("release_channel_unavailable_ignore_until"));
await page.reload({ waitUntil: "domcontentloaded" });
await expect(guard).toBeVisible({ timeout: GUARD_TIMEOUT_MS });
const sessionRequestCountBeforeLogout = sessionRequests.length;
await guard.getByRole("button", { name: /log\s*out|logout/i }).click();
await expect(page).toHaveURL(/\/login/);
await expect(page.locator(".swal2-popup")).toHaveCount(0);
await expect.poll(() => page.evaluate(() => window.localStorage.getItem("token"))).toBeNull();
await page.waitForTimeout(500);
expect(sessionRequests.length).toBe(sessionRequestCountBeforeLogout);
});
test("release channel choices show git commit and release time when available", async ({ page }) => { test("release channel choices show git commit and release time when available", async ({ page }) => {
const runtime = runtimeWithSelectableReleaseDetails(); const runtime = runtimeWithSelectableReleaseDetails();
await boot(page, runtime); await boot(page, runtime);
@@ -340,6 +535,47 @@ test("release channel choices show git commit and release time when available",
); );
}); });
test("ready sidebar release channel switches are confirmed through the control runtime", async ({ page }, testInfo) => {
test.skip(testInfo.project.name.includes("mobile"), "The sidebar release selector is hidden in the mobile layout.");
const stableRuntime = runtimeWithFailingBetaSwitch();
const betaRuntime = runtimeWithReadyBetaSelected();
await boot(page, stableRuntime);
const runtimeRequests = [];
await page.route("**/release/runtime**", async (route) => {
const url = new URL(route.request().url());
const selectedChannel = url.searchParams.get("release_channel") || "";
runtimeRequests.push(route.request().url());
await route.fulfill(json({ data: selectedChannel === "beta" ? betaRuntime : stableRuntime }));
});
await page.goto("/user", { waitUntil: "domcontentloaded" });
const stable = page.getByTestId("release-channel-option-stable");
const beta = page.getByTestId("release-channel-option-beta");
await expect(stable).toBeVisible({ timeout: GUARD_TIMEOUT_MS });
await expect(stable).toHaveAttribute("aria-pressed", "true");
await expect(beta).toHaveAttribute("aria-pressed", "false");
await beta.click();
await expect(page.getByTestId("release-channel-switched-page")).toContainText("You are now on Beta");
await expect(page.locator(".release-channel-sidebar-selector__error")).toHaveCount(0);
await expect
.poll(() => page.evaluate(() => window.localStorage.getItem("release_channel_selected_slug")))
.toBe("beta");
await page.getByTestId("release-channel-switched-continue").click();
await expect(beta).toHaveAttribute("aria-pressed", "true");
await expect(stable).toHaveAttribute("aria-pressed", "false");
const betaRuntimeRequest = runtimeRequests.find((url) => new URL(url).searchParams.get("release_channel") === "beta");
expect(betaRuntimeRequest).toBeTruthy();
const betaRuntimeRequestUrl = new URL(betaRuntimeRequest);
expect(["/api/release/runtime", "/master/api/release/runtime"]).toContain(betaRuntimeRequestUrl.pathname);
expect(betaRuntimeRequestUrl.pathname).not.toBe("/beta/api/release/runtime");
});
test("failed sidebar release channel switches keep the previous channel active", async ({ page }, testInfo) => { test("failed sidebar release channel switches keep the previous channel active", async ({ page }, testInfo) => {
test.skip(testInfo.project.name.includes("mobile"), "The sidebar release selector is hidden in the mobile layout."); test.skip(testInfo.project.name.includes("mobile"), "The sidebar release selector is hidden in the mobile layout.");
+185 -7
View File
@@ -1425,20 +1425,29 @@ async function installReleaseMocks(page, state) {
if (pathname.endsWith("/superuser/releases/service-sets") && method === "POST") { if (pathname.endsWith("/superuser/releases/service-sets") && method === "POST") {
const payload = request.postDataJSON?.() || {}; const payload = request.postDataJSON?.() || {};
const source = state.serviceSets.find((entry) => Number(entry.id) === Number(payload.source_service_set_id)); const source = state.serviceSets.find((entry) => Number(entry.id) === Number(payload.source_service_set_id));
const dataSource = state.serviceSets.find(
(entry) => Number(entry.id) === Number(payload.data_source_service_set_id)
);
const mode = payload.mode || "attach_existing";
const serviceSet = { const serviceSet = {
id: state.nextServiceSetId++, id: state.nextServiceSetId++,
channel_id: payload.channel_id || source?.channel_id || 2, channel_id: payload.channel_id || source?.channel_id || 2,
channel_slug: channelSlug(state, payload.channel_id || source?.channel_id || 2), channel_slug: channelSlug(state, payload.channel_id || source?.channel_id || 2),
name: payload.name || "Fresh release stack", name: payload.name || "Fresh release stack",
slug: (payload.name || "fresh-release-stack").toLowerCase().replace(/[^a-z0-9]+/g, "-"), slug: (payload.name || "fresh-release-stack").toLowerCase().replace(/[^a-z0-9]+/g, "-"),
mode: payload.mode || "attach_existing", mode,
data_policy: payload.metadata?.data_policy || (mode === "attach_existing" ? "production_shared" : mode),
data_service_mode:
payload.metadata?.data_service_mode || (mode === "attach_existing" ? "production_shared" : mode),
source_service_set_id: payload.source_service_set_id || null, source_service_set_id: payload.source_service_set_id || null,
data_source_service_set_id: payload.data_source_service_set_id || null,
data_source_channel_slug: payload.metadata?.data_source_channel_slug || dataSource?.channel_slug || null,
status: status:
payload.mode === "clone_existing" mode === "clone_existing"
? "provisioning" ? "provisioning"
: payload.mode === "fresh_empty" : mode === "fresh_empty"
? "isolated_empty" ? "isolated_empty"
: payload.mode === "isolated_stack" : mode === "isolated_stack"
? "isolated_stack" ? "isolated_stack"
: "ready", : "ready",
targets: { targets: {
@@ -1451,16 +1460,18 @@ async function installReleaseMocks(page, state) {
source?.targets?.api || source?.targets?.api ||
null, null,
}, },
data_services: ["fresh_empty", "isolated_stack"].includes(payload.mode) data_services: ["fresh_empty", "isolated_stack"].includes(mode)
? { database: null, redis: null, minio: null } ? { database: null, redis: null, minio: null }
: source?.data_services || { : dataSource?.data_services ||
source?.data_services || {
database: null, database: null,
redis: null, redis: null,
minio: null, minio: null,
}, },
metadata: payload.metadata || {},
attached_bundles: [], attached_bundles: [],
}; };
if (payload.mode === "isolated_stack" && payload.create_data_targets !== false) { if (mode === "isolated_stack" && payload.create_data_targets !== false) {
completeIsolatedDataServices(state, serviceSet); completeIsolatedDataServices(state, serviceSet);
} }
state.serviceSets.unshift(serviceSet); state.serviceSets.unshift(serviceSet);
@@ -2294,6 +2305,173 @@ test("superusers manage release settings, assignments, integrations, and sync op
await expect(page.getByTestId("release-operation-grid-row").first()).toContainText("canary"); await expect(page.getByTestId("release-operation-grid-row").first()).toContainText("canary");
}); });
test("beta bundle flow locks to production data sharing @beta-production-data", async ({ page }) => {
const state = createReleaseState();
state.nextTargetId = 40;
state.nextServiceSetId = 60;
const stableFrontend = {
id: 20,
channel_id: 1,
channel_slug: "stable",
app: "frontend",
repository: "truckwash/front-end-vue",
branch: DEFAULT_RELEASE_BRANCH,
coolify_instance_id: 3,
coolify_instance_label: "Production Coolify",
coolify_service_uuid: "frontend-stable-service",
health_url: "https://app.example.test/health",
auto_deploy: true,
deploy_context: {
coolify_public_url: "https://app.example.test",
coolify_project_uuid: "project-main",
coolify_environment_name: "production",
},
};
const stableApi = {
id: 21,
channel_id: 1,
channel_slug: "stable",
app: "api",
repository: "truckwash/backend-php",
branch: DEFAULT_RELEASE_BRANCH,
coolify_instance_id: 3,
coolify_instance_label: "Production Coolify",
coolify_service_uuid: "api-stable-service",
health_url: "https://api.example.test/ping",
auto_deploy: true,
deploy_context: {
coolify_public_url: "https://api.example.test",
coolify_project_uuid: "project-main",
coolify_environment_name: "production",
},
};
const betaFrontend = {
id: 30,
channel_id: 3,
channel_slug: "beta",
app: "frontend",
repository: "truckwash/front-end-vue",
branch: "release/beta",
coolify_instance_id: 3,
coolify_instance_label: "Production Coolify",
coolify_service_uuid: "frontend-beta-service",
health_url: "https://beta.example.test/health",
auto_deploy: true,
deploy_context: {
coolify_public_url: "https://beta.example.test",
coolify_project_uuid: "project-main",
coolify_environment_name: "production",
},
};
const betaApi = {
id: 31,
channel_id: 3,
channel_slug: "beta",
app: "api",
repository: "truckwash/backend-php",
branch: "release/beta",
coolify_instance_id: 3,
coolify_instance_label: "Production Coolify",
coolify_service_uuid: "api-beta-service",
health_url: "https://api-beta.example.test/ping",
auto_deploy: true,
deploy_context: {
coolify_public_url: "https://api-beta.example.test",
coolify_project_uuid: "project-main",
coolify_environment_name: "production",
},
};
const stableSet = {
id: 50,
channel_id: 1,
channel_slug: "stable",
name: "Stable production stack",
slug: "stable-production-stack",
mode: "attach_existing",
data_policy: "production_shared",
data_service_mode: "production_shared",
status: "ready",
active: true,
targets: {
frontend: stableFrontend,
api: stableApi,
},
data_services: {
database: {
id: 70,
kind: "database",
label: "Production MariaDB",
resource_name: "mariadb-production",
deployment_status: "running",
replication: { label: "prod-mariadb", host: "db.prod.internal", port: 3306, status: "ok" },
},
redis: {
id: 71,
kind: "redis",
label: "Production Redis",
resource_name: "redis-production",
deployment_status: "running",
replication: { label: "prod-redis", host: "redis.prod.internal", port: 6379, status: "ok" },
},
minio: {
id: 72,
kind: "minio",
label: "Production MinIO",
resource_name: "minio-production",
deployment_status: "running",
replication: { label: "prod-minio", host: "minio.prod.internal", port: 9000, status: "ok" },
},
},
attached_bundles: [],
};
state.targets.push(stableFrontend, stableApi, betaFrontend, betaApi);
state.serviceSets.unshift(stableSet);
await boot(page, state);
await page.goto("/superuser/configuration/releases/overview?channel=beta&app=all&branch=release/beta", {
waitUntil: "domcontentloaded",
});
await selectReleaseTab(page, "Deployments");
await expandActiveReleaseCategory(page);
const bundleFlow = page.getByTestId("release-bundle-flow");
const bundleChannel = bundleFlow.getByTestId("release-bundle-channel");
await bundleChannel.selectOption("3");
await expect(bundleChannel).toHaveValue("3");
await expect(bundleFlow.getByTestId("release-dataset-mode-attach_existing")).not.toBeDisabled();
await expect(bundleFlow.getByTestId("release-dataset-mode-clone_existing")).toBeDisabled();
await expect(bundleFlow.getByTestId("release-dataset-mode-fresh_empty")).toBeDisabled();
await expect(bundleFlow.getByTestId("release-dataset-mode-isolated_stack")).toBeDisabled();
await expect(bundleFlow).toContainText("Beta is locked to production-shared data");
await expect(bundleFlow).toContainText("Beta uses production data");
await expect(bundleFlow.getByTestId("release-bundle-source-service-set")).toHaveValue("50");
await expect(bundleFlow.getByTestId("release-service-action-table")).toContainText("production_shared");
await expect(bundleFlow.getByTestId("release-service-action-table")).toContainText("api-beta-service");
await bundleFlow.getByRole("button", { name: "Next" }).click();
await expect(bundleFlow.getByPlaceholder("owner/frontend", { exact: true })).toHaveValue("truckwash/front-end-vue");
await expect(bundleFlow.getByPlaceholder("owner/backend-php", { exact: true })).toHaveValue("truckwash/backend-php");
await expect(bundleFlow.getByPlaceholder("master").nth(0)).toHaveValue("release/beta");
await expect(bundleFlow.getByPlaceholder("master").nth(1)).toHaveValue("release/beta");
await bundleFlow.getByRole("button", { name: "Next" }).click();
await expect(page.getByTestId("release-bundle-review-stack")).toContainText("production_shared");
await page.getByTestId("release-bundle-deploy-submit").click();
await expect(page.getByTestId("release-created-bundle")).toContainText("Bundle deployed");
const betaSet = state.serviceSets.find((set) => set.name === "beta-production-data");
expect(betaSet).toBeTruthy();
expect(betaSet.channel_slug).toBe("beta");
expect(betaSet.source_service_set_id).toBeNull();
expect(betaSet.data_source_service_set_id).toBe(stableSet.id);
expect(betaSet.data_policy).toBe("production_shared");
expect(betaSet.targets.frontend.id).toBe(betaFrontend.id);
expect(betaSet.targets.api.id).toBe(betaApi.id);
expect(betaSet.data_services.database.id).toBe(stableSet.data_services.database.id);
expect(state.bundlePayloads[0].service_set_id).toBe(betaSet.id);
expect(state.bundlePayloads[0].service_set_id).not.toBe(stableSet.id);
});
test("isolated stack mode creates fresh Coolify app and data targets without attaching production data", async ({ test("isolated stack mode creates fresh Coolify app and data targets without attaching production data", async ({
page, page,
}) => { }) => {
+1 -3
View File
@@ -133,9 +133,7 @@ test("@public-live release manifest, shell, and static assets are available", as
test("@public-live api-v2 gateway and channel API prefixes serve JSON ping responses", async ({ request }) => { test("@public-live api-v2 gateway and channel API prefixes serve JSON ping responses", async ({ request }) => {
const apiBaseUrl = process.env.PLAYWRIGHT_RELEASE_API_BASE_URL || "https://api-v2.truckwash.io"; const apiBaseUrl = process.env.PLAYWRIGHT_RELEASE_API_BASE_URL || "https://api-v2.truckwash.io";
const apiPingPaths = unique( const apiPingPaths = unique(
(process.env.PLAYWRIGHT_RELEASE_API_PING_PATHS || "/ping,/master/api/ping,/canary/api/ping,/stable/api/ping") (process.env.PLAYWRIGHT_RELEASE_API_PING_PATHS || "/master/api/ping").split(",").map((value) => value.trim())
.split(",")
.map((value) => value.trim())
); );
for (const apiPingPath of apiPingPaths) { for (const apiPingPath of apiPingPaths) {
+5 -6
View File
@@ -29,15 +29,15 @@ describe("release bootstrap", () => {
localStorage.setItem("release_channel_selected_slug", "Canary Preview!"); localStorage.setItem("release_channel_selected_slug", "Canary Preview!");
expect(runtimeApiUrl("https://api.truckwash.io")).toBe( expect(runtimeApiUrl("https://api.truckwash.io")).toBe(
"https://api-v2.truckwash.io/canary-preview/api/release/runtime?release_channel=canary-preview" "https://api.truckwash.io/release/runtime?release_channel=canary-preview"
); );
}); });
it("routes stable runtime requests through the public master API prefix", () => { it("uses the control API for selected stable runtime requests", () => {
localStorage.setItem("release_channel_selected_slug", "stable"); localStorage.setItem("release_channel_selected_slug", "stable");
expect(runtimeApiUrl("https://api.truckwash.io")).toBe( expect(runtimeApiUrl("https://api.truckwash.io")).toBe(
"https://api-v2.truckwash.io/master/api/release/runtime?release_channel=stable" "https://api.truckwash.io/release/runtime?release_channel=stable"
); );
}); });
@@ -52,16 +52,15 @@ describe("release bootstrap", () => {
it("attaches release trace, channel, and frontend headers to runtime requests", async () => { it("attaches release trace, channel, and frontend headers to runtime requests", async () => {
localStorage.setItem("release_trace_id", "trace-runtime"); localStorage.setItem("release_trace_id", "trace-runtime");
localStorage.setItem("release_channel_selected_slug", "Internal");
const fetchFn = vi.fn(async () => ({ const fetchFn = vi.fn(async () => ({
ok: true, ok: true,
json: async () => ({ data: { channel: { slug: "internal" } } }), json: async () => ({ data: { channel: { slug: "internal" } } }),
})); }));
await fetchReleaseRuntime({ fetchFn, apiBaseUrl: "https://api.truckwash.io" }); await fetchReleaseRuntime({ fetchFn, apiBaseUrl: "https://api.truckwash.io", selectedChannel: "Internal" });
const [url, options] = fetchFn.mock.calls[0]; const [url, options] = fetchFn.mock.calls[0];
expect(url).toBe("https://api-v2.truckwash.io/internal/api/release/runtime?release_channel=internal"); expect(url).toBe("https://api.truckwash.io/release/runtime?release_channel=internal");
expect(options.headers).toMatchObject({ expect(options.headers).toMatchObject({
"X-Release-Trace": "trace-runtime", "X-Release-Trace": "trace-runtime",
"X-Release-Channel": "internal", "X-Release-Channel": "internal",
@@ -0,0 +1,36 @@
// @vitest-environment jsdom
import { describe, expect, it } from "vitest";
import { getReleaseChannelUnavailableStatus } from "@/services/releaseChannelAvailability.js";
describe("internal release channel availability", () => {
it("blocks assigned internal channel payloads that are missing a frontend entry", () => {
const status = getReleaseChannelUnavailableStatus(
{
channel: {
slug: "internal",
name: "Intern",
description: "Internal staff and superuser validation channel.",
default_channel: false,
},
versions: {
frontend: null,
api: { version_label: "api-internal", commit_sha: "e40cf6b6ac31" },
bundle_id: null,
},
availability: {
configured: false,
explicit: true,
missing: ["frontend_entry"],
status: "unconfigured",
},
},
1_000,
0
);
expect(status.shouldBlock).toBe(true);
expect(status.channelSlug).toBe("internal");
expect(status.channelName).toBe("Intern");
expect(status.missing).toEqual(["frontend_entry"]);
});
});
+88
View File
@@ -0,0 +1,88 @@
import { describe, expect, it } from "vitest";
import { normalizeSessionPayload } from "@/services/sessionPayload.js";
describe("session payload normalization", () => {
it("defaults optional session fields that old cached payloads can omit", () => {
const session = normalizeSessionPayload({
id: 42,
customer_number: 10042,
email: "fleet@example.test",
});
expect(session).toMatchObject({
id: 42,
customer_number: 10042,
email: "fleet@example.test",
phone: {
number: null,
country_code: null,
},
notifications: {
wash_certificate_email: null,
email_notifications_enabled: null,
sms_notifications_enabled: null,
},
permissions: [],
economic_customer: null,
runtime_config: {
economic: {
transaction_draft_customer_number: null,
default_distribution_department_id: null,
},
release: {},
},
});
});
it("accepts object-shaped economic customers and release runtime config", () => {
const releaseRuntime = {
channel: { slug: "internal", name: "Intern", default_channel: false },
availability: {
configured: false,
missing: ["frontend_entry"],
status: "unconfigured",
},
};
const session = normalizeSessionPayload({
permissions: ["user"],
economic_customer: {
customerNumber: 12345,
name: "Nordic Haul",
},
runtime_config: {
economic: {
transaction_draft_customer_number: "777",
default_distribution_department_id: 12,
},
release: releaseRuntime,
},
});
expect(session.permissions).toEqual(["user"]);
expect(session.economic_customer).toEqual({
customerNumber: 12345,
name: "Nordic Haul",
});
expect(session.runtime_config.economic.transaction_draft_customer_number).toBe("777");
expect(session.runtime_config.economic.default_distribution_department_id).toBe(12);
expect(session.runtime_config.release).toBe(releaseRuntime);
});
it("uses the first object from legacy economic customer arrays", () => {
const session = normalizeSessionPayload({
economic_customer: [
null,
{
customerNumber: 12345,
name: "Nordic Haul",
},
],
});
expect(session.economic_customer).toEqual({
customerNumber: 12345,
name: "Nordic Haul",
});
});
});