Merge remote-tracking branch 'origin/master' into propose-fix-for-self-hosted-ci-vulnerability

# Conflicts:
#	.github/workflows/tests.yml
This commit is contained in:
copilot-swe-agent[bot]
2026-06-01 21:43:04 +00:00
committed by GitHub
14 changed files with 504 additions and 28 deletions
@@ -7,6 +7,13 @@ import { getSystemUserIds } from "@/components/session/token/SessionUser/Objects
// Helper function to get i18n translation
const t = (key) => i18n.global.t(key);
const escapeHtml = (value = "") => String(value)
.replaceAll("&", "&")
.replaceAll("<", "&lt;")
.replaceAll(">", "&gt;")
.replaceAll("\"", "&quot;")
.replaceAll("'", "&#39;");
const normalizeDateTimeLocalValue = (value) => {
if (value === null || value === undefined || value === '') {
return '';
@@ -765,9 +772,11 @@ export const ObjectsGlobal = {
console.log(response);
// How long are the list of options
for (let i = 0; i < response.length; i++) {
var tmp = response[i];
const tmp = response[i];
const optionValue = tmp?.id ?? "";
const optionLabel = tmp?.name ? tmp.name : optionValue;
console.log(tmp);
html += `<option value="${tmp.id}" ${(value == tmp.id || (value === null && tmp.id === 0)) ? 'selected' : ''}>${tmp.name ? tmp.name : tmp.id}</option>`;
html += `<option value="${escapeHtml(optionValue)}" ${(value == optionValue || (value === null && optionValue === 0)) ? 'selected' : ''}>${escapeHtml(optionLabel)}</option>`;
}
});
html += `</select>
+5 -5
View File
@@ -1,5 +1,6 @@
import { computed, ref } from "vue";
import { SessionUser } from "@/components/session/token/SessionUser.vue";
import { safeAttachmentDownloadLink } from "@/services/attachmentDownloadLinks.js";
const normalizeBooleanAnswer = (value) => {
if (value === true || value === false) {
@@ -526,7 +527,7 @@ export function useSelfServeLogic() {
};
const downloadAttachment = async (taskId, attachmentId, attachment = null) => {
const existingDownloadLink = attachment?.download_link || null;
const existingDownloadLink = safeAttachmentDownloadLink(attachment?.download_link);
if (existingDownloadLink) {
window.open(existingDownloadLink, "_blank", "noopener");
return;
@@ -534,10 +535,9 @@ export function useSelfServeLogic() {
try {
const response = await SessionUser.objects.self_serve_tasks.attachments.download(taskId, attachmentId);
if (response?.data?.download_link) {
window.open(response.data.download_link, "_blank", "noopener");
} else if (response?.download_link) {
window.open(response.download_link, "_blank", "noopener");
const downloadLink = safeAttachmentDownloadLink(response?.data?.download_link || response?.download_link);
if (downloadLink) {
window.open(downloadLink, "_blank", "noopener");
}
} catch (error) {
console.error("Error downloading attachment:", error);
+6
View File
@@ -55,6 +55,12 @@ export const RELEASE_TRUSTED_ORIGINS = String(import.meta.env.VITE_RELEASE_TRUST
.split(",")
.map((origin) => origin.trim().replace(/\/+$/, ""))
.filter(Boolean);
export const SELF_SERVE_ATTACHMENT_TRUSTED_ORIGINS = String(
import.meta.env.VITE_SELF_SERVE_ATTACHMENT_TRUSTED_ORIGINS || ""
)
.split(",")
.map((origin) => origin.trim().replace(/\/+$/, ""))
.filter(Boolean);
// Allowed origins
export const ALLOWED_ORIGINS = [
@@ -10,6 +10,66 @@ const MOCK_SHELL_PROMPT = "edge@truckwash:/opt/truckwash-edge-agent$ ";
const isMockSocketUrl = (value) => String(value || "").startsWith(MOCK_SOCKET_PREFIX);
const SENSITIVE_SOCKET_QUERY_KEYS = new Set([
"access_token",
"auth",
"authorization",
"bearer",
"jwt",
"session",
"session_token",
"token",
]);
const getBrowserLocation = () => (typeof window !== "undefined" && window.location ? window.location : null);
const getConfiguredTrustedSocketOrigins = () =>
String(import.meta.env?.VITE_EDGE_GATEWAY_WS_ALLOWED_ORIGINS || "")
.split(",")
.map((entry) => entry.trim())
.filter(Boolean);
const toSocketOrigin = (value) => {
const location = getBrowserLocation();
const base = location?.origin || "http://localhost";
const url = new URL(value, base);
if (url.protocol === "https:") {
url.protocol = "wss:";
} else if (url.protocol === "http:") {
url.protocol = "ws:";
}
return url.origin;
};
const isLocalSocketHost = (hostname) => ["localhost", "127.0.0.1", "::1", "[::1]"].includes(String(hostname || ""));
const getTrustedSocketOrigins = () => {
const origins = new Set();
const location = getBrowserLocation();
if (location?.origin) {
origins.add(toSocketOrigin(location.origin));
}
getConfiguredTrustedSocketOrigins().forEach((origin) => origins.add(toSocketOrigin(origin)));
return origins;
};
const assertTrustedSocketUrl = (url) => {
if (!["ws:", "wss:"].includes(url.protocol)) {
throw new Error("Unsupported websocket URL protocol");
}
if (url.protocol !== "wss:" && !isLocalSocketHost(url.hostname)) {
throw new Error("Gateway websocket URL must use wss");
}
const trustedOrigins = getTrustedSocketOrigins();
if (trustedOrigins.size > 0 && !trustedOrigins.has(url.origin)) {
throw new Error("Gateway websocket URL origin is not trusted");
}
};
const toNativeSocketUrl = (socketUrl, query = {}) => {
if (!socketUrl) {
throw new Error("Missing websocket URL");
@@ -19,16 +79,48 @@ const toNativeSocketUrl = (socketUrl, query = {}) => {
return socketUrl;
}
const url = new URL(socketUrl, typeof window !== "undefined" ? window.location.origin : "http://localhost");
const url = new URL(socketUrl, getBrowserLocation()?.origin || "http://localhost");
assertTrustedSocketUrl(url);
Array.from(url.searchParams.keys()).forEach((key) => {
if (SENSITIVE_SOCKET_QUERY_KEYS.has(String(key).toLowerCase())) {
url.searchParams.delete(key);
}
});
Object.entries(query || {}).forEach(([key, value]) => {
if (value === null || value === undefined || value === "") {
return;
}
if (SENSITIVE_SOCKET_QUERY_KEYS.has(String(key).toLowerCase())) {
return;
}
url.searchParams.set(key, String(value));
});
return url.toString();
};
const createSessionAuthenticationPayload = (session, gatewayId, kind) => {
if (!session?.token) {
return null;
}
return {
type: "AUTH",
gatewayId: String(gatewayId || session.gateway_id || ""),
token: String(session.token),
sessionId: session.id || session.session_id || null,
kind,
};
};
const sendSessionAuthentication = (send, session, gatewayId, kind) => {
const authPayload = createSessionAuthenticationPayload(session, gatewayId, kind);
if (authPayload) {
send(authPayload);
}
};
const safeJsonParse = (payload) => {
if (payload === null || payload === undefined || payload === "") {
return null;
@@ -292,10 +384,10 @@ export async function createGatewayStreamClient(gatewayId, scopes = [], handlers
const session = unwrapEdgeGatewayResponse(response, {});
const socketUrl = toNativeSocketUrl(session.ws_url, {
gatewayId,
token: session.token,
});
const connection = createConnection(socketUrl, session, "stream", handlers, ({ send }) => {
sendSessionAuthentication(send, session, gatewayId, "stream");
send({
type: "SUBSCRIBE",
gatewayId: String(gatewayId),
@@ -318,9 +410,10 @@ export async function createGatewayShellClient(gatewayId, options = {}, handlers
const session = unwrapEdgeGatewayResponse(response, {});
const socketUrl = toNativeSocketUrl(session.ws_url, {
gatewayId,
token: session.token,
});
const connection = createConnection(socketUrl, session, "shell", handlers);
const connection = createConnection(socketUrl, session, "shell", handlers, ({ send }) => {
sendSessionAuthentication(send, session, gatewayId, "shell");
});
return {
session,
+55
View File
@@ -0,0 +1,55 @@
import { SELF_SERVE_ATTACHMENT_TRUSTED_ORIGINS } from "@/config.js";
import { trustedReleaseOrigins } from "@/services/releaseTrust.js";
const normalizeOrigin = (value) => {
const raw = String(value || "").trim();
if (!raw) {
return "";
}
try {
return new URL(raw).origin;
} catch {
return "";
}
};
const isLocalHttpOrigin = (url) =>
url.protocol === "http:" && ["localhost", "127.0.0.1", "::1"].includes(url.hostname);
const attachmentTrustedOrigins = () =>
Array.from(
new Set(
[...trustedReleaseOrigins(), ...SELF_SERVE_ATTACHMENT_TRUSTED_ORIGINS]
.map(normalizeOrigin)
.filter(Boolean)
)
);
export const isSafeAttachmentDownloadLink = (value) => {
const raw = String(value || "").trim();
if (!raw || raw.startsWith("//")) {
return false;
}
if (raw.startsWith("/")) {
return true;
}
let url;
try {
url = new URL(raw);
} catch {
return false;
}
if (url.protocol !== "https:" && !isLocalHttpOrigin(url)) {
return false;
}
return attachmentTrustedOrigins().includes(url.origin);
};
export const safeAttachmentDownloadLink = (value) => {
const raw = String(value || "").trim();
return isSafeAttachmentDownloadLink(raw) ? raw : "";
};
@@ -18,6 +18,7 @@ import {
isSearching as isSearchingCustomers,
} from "@/components/search/economic/customerSearch.vue";
import { resolveReleaseApiUrl } from "@/services/releaseTimeline.js";
import { safeAttachmentDownloadLink } from "@/services/attachmentDownloadLinks.js";
import {
buildSelfServeDynamicImageUrl,
getSelfServeCompletedDynamicImageStep,
@@ -482,6 +483,9 @@ const graphStats = computed(() => {
});
const permissions = computed(() => graphPayload.value?.permissions || {});
const canEditVehicleTypeProduct = computed(() =>
Boolean(permissions.value.can_edit) && SessionUser.canAccessSuperUser()
);
const destructiveGatewayActions = new Set(["rotate_credentials", "uninstall"]);
const canManageGateways = computed(() => Boolean(permissions.value.can_manage_gateways));
const canRunGatewayDestructiveActions = computed(() =>
@@ -3158,14 +3162,14 @@ const inspectorTaskDynamicImageUnavailableReason = computed(() => {
});
const openTaskAttachment = async (attachment, taskId = null) => {
let downloadLink = String(attachment?.download_link || "").trim();
let downloadLink = safeAttachmentDownloadLink(attachment?.download_link);
if (!downloadLink && taskId && attachment?.id) {
try {
const payload = await requestGet("/department/selfserve/tasks/attachments/download", {
task_id: taskId,
attachment_id: attachment.id,
});
downloadLink = String(payload?.download_link || "").trim();
downloadLink = safeAttachmentDownloadLink(payload?.download_link);
} catch (error) {
withErrorToast(error, "Attachment download could not be prepared.");
return;
@@ -4686,6 +4690,11 @@ const deleteSelected = async () => {
};
const saveVehicleTypeProduct = async () => {
if (!canEditVehicleTypeProduct.value) {
toast.error("Only superusers can update vehicle type product catalog fields.");
return;
}
const id = parseNullableInt(
inspectorForm.value.id || selectedRaw.value.product || selectedRaw.value.product_id || selectedRaw.value.id
);
@@ -7054,7 +7063,7 @@ onBeforeUnmount(() => {
class="studio-debug-attachment"
:disabled="!attachment.download_link"
:title="taskAttachmentLabel(attachment)"
@click.stop="openTaskAttachment(attachment)"
@click.stop="openTaskAttachment(attachment, task.id)"
>
<span class="icon is-small"><i :class="taskAttachmentIcon(attachment)"></i></span>
<span>{{ taskAttachmentLabel(attachment) }}</span>
@@ -7512,7 +7521,7 @@ onBeforeUnmount(() => {
class="studio-node-attachment"
:disabled="!attachment.download_link"
:title="taskAttachmentLabel(attachment)"
@click.stop="openTaskAttachment(attachment)"
@click.stop="openTaskAttachment(attachment, data.object_id)"
@mousedown.stop
>
<span class="icon is-small"><i :class="taskAttachmentIcon(attachment)"></i></span>
@@ -8053,7 +8062,7 @@ onBeforeUnmount(() => {
/>
<span>Wash product</span>
</label>
<button class="button is-primary" type="submit" :disabled="!permissions.can_edit">
<button class="button is-primary" type="submit" :disabled="!canEditVehicleTypeProduct">
<span class="icon"><i class="fas fa-floppy-disk"></i></span>
<span>Save vehicle type</span>
</button>
@@ -9838,7 +9847,7 @@ onBeforeUnmount(() => {
class="studio-debug-attachment"
:disabled="!attachment.download_link"
:title="taskAttachmentLabel(attachment)"
@click.stop="openTaskAttachment(attachment)"
@click.stop="openTaskAttachment(attachment, task.id)"
>
<span class="icon is-small"><i :class="taskAttachmentIcon(attachment)"></i></span>
<span>{{ taskAttachmentLabel(attachment) }}</span>
@@ -26,8 +26,11 @@ 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 canManageMachineStatusToggles = computed(() =>
SessionUser.hasPermission("admin") || SessionUser.hasPermission("modules_selfserve_lane_status_set")
);
const canManageDognvaskToggles = computed(() =>
SessionUser.hasPermission("admin") || SessionUser.hasPermission("edit_department_lane")
);
const normalizeBoolean = (value, defaultValue = false) => {
@@ -252,7 +255,7 @@ const applyLaneUpdate = (laneId, currentLane, update, savingPatch = {}) => {
};
const toggleMachineStatus = async (lane, event) => {
if (!canManageLaneToggles.value) {
if (!canManageMachineStatusToggles.value) {
event.target.checked = isMachineToggleChecked(lane);
return;
}
@@ -286,7 +289,7 @@ const toggleMachineStatus = async (lane, event) => {
};
const toggleDognvask = async (lane, event) => {
if (!canManageLaneToggles.value) {
if (!canManageDognvaskToggles.value) {
event.target.checked = isDognvaskToggleChecked(lane);
return;
}
@@ -348,7 +351,7 @@ watch([selected_date, selected_date_to], () => {
type="checkbox"
:id="machineToggleId(lane)"
:checked="isMachineToggleChecked(lane)"
:disabled="!canManageLaneToggles || lane.isSavingMachineStatus"
:disabled="!canManageMachineStatusToggles || lane.isSavingMachineStatus"
@change="toggleMachineStatus(lane, $event)"
>
<div class="status-toggle-top">
@@ -384,7 +387,7 @@ watch([selected_date, selected_date_to], () => {
type="checkbox"
:id="dognvaskToggleId(lane)"
:checked="isDognvaskToggleChecked(lane)"
:disabled="!canManageLaneToggles || lane.isSavingDognvask"
:disabled="!canManageDognvaskToggles || lane.isSavingDognvask"
@change="toggleDognvask(lane, $event)"
>
<div class="status-toggle-top">
@@ -0,0 +1,16 @@
import { describe, expect, it } from "vitest";
import { isSafeAttachmentDownloadLink, safeAttachmentDownloadLink } from "@/services/attachmentDownloadLinks.js";
describe("attachmentDownloadLinks", () => {
it("allows relative and trusted HTTPS attachment URLs", () => {
expect(isSafeAttachmentDownloadLink("/department/selfserve/tasks/attachments/download?token=abc")).toBe(true);
expect(isSafeAttachmentDownloadLink("https://api-v2.truckwash.io/master/api/attachments/123")).toBe(true);
});
it("rejects scriptable, protocol-relative, and untrusted attachment URLs", () => {
expect(safeAttachmentDownloadLink("javascript:alert(document.domain)")).toBe("");
expect(safeAttachmentDownloadLink("data:text/html,<script>alert(document.domain)</script>")).toBe("");
expect(safeAttachmentDownloadLink("//evil.example/attachment.pdf")).toBe("");
expect(safeAttachmentDownloadLink("https://evil.example/attachment.pdf")).toBe("");
});
});
@@ -144,7 +144,9 @@ describe("Department overview period sync behavior", () => {
setMachineStatusEnabledMock.mockReset();
setLaneSelfServeEnabledMock.mockReset();
hasPermissionMock.mockReset();
hasPermissionMock.mockImplementation((permission) => permission === "list_department_wash_lanes");
hasPermissionMock.mockImplementation((permission) =>
["modules_selfserve_lane_status_set", "edit_department_lane"].includes(permission)
);
getDepartmentMock.mockResolvedValue({ id: 20, name: "Dept 20" });
getLaneStatusTogglesMock.mockResolvedValue({ data: { data: [] } });
@@ -443,7 +445,7 @@ describe("Department overview period sync behavior", () => {
expect(wrapper.find("#dognvask-20-22").element.checked).toBe(false);
});
it("disables lane mutation toggles for daily-report users without wash-lane permission", async () => {
it("disables lane mutation toggles for users without lane mutation permissions", async () => {
hasPermissionMock.mockImplementation((permission) => permission === "list_department_daily_reports");
getLaneStatusTogglesMock.mockResolvedValueOnce({
data: {
@@ -499,4 +501,61 @@ describe("Department overview period sync behavior", () => {
expect(setMachineStatusEnabledMock).not.toHaveBeenCalled();
expect(setLaneSelfServeEnabledMock).not.toHaveBeenCalled();
});
it("does not allow wash-lane list permission to mutate lane toggles", async () => {
hasPermissionMock.mockImplementation((permission) => permission === "list_department_wash_lanes");
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();
});
});
@@ -0,0 +1,137 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
const streamSessionMock = vi.fn();
const shellSessionMock = vi.fn();
vi.mock("@/services/edgeGateways.js", () => ({
createEdgeGatewayStreamSession: (...args) => streamSessionMock(...args),
createEdgeGatewayShellSession: (...args) => shellSessionMock(...args),
unwrapEdgeGatewayResponse: (response, fallback = {}) => response?.data?.data || response?.data || fallback,
}));
class MockWebSocket {
static instances = [];
constructor(url) {
this.url = url;
this.readyState = 0;
this.sent = [];
this.listeners = new Map();
MockWebSocket.instances.push(this);
}
addEventListener(type, handler) {
this.listeners.set(type, handler);
}
send(payload) {
this.sent.push(JSON.parse(payload));
}
close() {
this.readyState = 3;
}
open() {
this.readyState = 1;
this.listeners.get("open")?.({});
}
}
const setBrowserLocation = (origin) => {
globalThis.window = {
location: new URL(origin),
setTimeout,
clearTimeout,
};
};
describe("edge gateway live session websocket security", () => {
beforeEach(() => {
vi.clearAllMocks();
MockWebSocket.instances = [];
globalThis.WebSocket = MockWebSocket;
setBrowserLocation("https://app.example/");
});
it("keeps stream session tokens out of websocket URLs and authenticates after open", async () => {
streamSessionMock.mockResolvedValue({
data: {
data: {
ws_url: "wss://app.example/gateway-stream?keep=1&token=URL-TOKEN&jwt=URL-JWT",
token: "STREAM-TOKEN-secret-456",
id: "stream-session-1",
},
},
});
const { createGatewayStreamClient } = await import("@/features/edgeGateways/edgeGatewayLiveSessions.js");
await createGatewayStreamClient("gateway-8", ["logs"]);
expect(MockWebSocket.instances).toHaveLength(1);
const socket = MockWebSocket.instances[0];
expect(socket.url).toBe("wss://app.example/gateway-stream?keep=1&gatewayId=gateway-8");
expect(socket.url).not.toContain("STREAM-TOKEN-secret-456");
expect(socket.url).not.toContain("URL-TOKEN");
expect(socket.url).not.toContain("URL-JWT");
socket.open();
expect(socket.sent[0]).toEqual({
type: "AUTH",
gatewayId: "gateway-8",
token: "STREAM-TOKEN-secret-456",
sessionId: "stream-session-1",
kind: "stream",
});
expect(socket.sent[1]).toEqual({ type: "SUBSCRIBE", gatewayId: "gateway-8", scopes: ["logs"] });
});
it("keeps shell session tokens out of websocket URLs and authenticates before input", async () => {
shellSessionMock.mockResolvedValue({
data: {
data: {
ws_url: "wss://app.example/live-shell?logme=1&token=URL-SHELL-TOKEN",
token: "SHELL-TOKEN-secret-123",
session_id: "shell-session-7",
},
},
});
const { createGatewayShellClient } = await import("@/features/edgeGateways/edgeGatewayLiveSessions.js");
const client = await createGatewayShellClient("gateway-7");
const socket = MockWebSocket.instances[0];
expect(socket.url).toBe("wss://app.example/live-shell?logme=1&gatewayId=gateway-7");
expect(socket.url).not.toContain("SHELL-TOKEN-secret-123");
expect(socket.url).not.toContain("URL-SHELL-TOKEN");
socket.open();
client.sendInput("pwd");
expect(socket.sent[0]).toEqual({
type: "AUTH",
gatewayId: "gateway-7",
token: "SHELL-TOKEN-secret-123",
sessionId: "shell-session-7",
kind: "shell",
});
expect(socket.sent[1]).toEqual({ type: "input", data: "pwd" });
});
it("rejects untrusted or insecure websocket endpoints before connecting", async () => {
shellSessionMock.mockResolvedValue({
data: {
data: {
ws_url: "ws://broker.invalid/live-shell",
token: "SHELL-TOKEN-secret-123",
},
},
});
const { createGatewayShellClient } = await import("@/features/edgeGateways/edgeGatewayLiveSessions.js");
await expect(createGatewayShellClient("gateway-7")).rejects.toThrow("Gateway websocket URL must use wss");
expect(MockWebSocket.instances).toHaveLength(0);
});
});
@@ -0,0 +1,55 @@
import { describe, expect, it, vi } from "vitest";
vi.mock("sweetalert2", () => ({
default: {
fire: vi.fn(),
},
}));
vi.mock("@/i18n", () => ({
default: {
global: {
t: (key) => key,
},
},
}));
vi.mock("@/components/session/authenticatedRequest.vue", () => ({
authenticatedRequest: vi.fn(),
unauthenticatedRequest: vi.fn(),
}));
vi.mock("@/components/session/token/SessionUser/Objects/systemUserIds.js", () => ({
getSystemUserIds: () => [],
}));
import { ObjectsGlobal } from "@/components/session/token/SessionUser/Objects/ObjectsGlobal.vue";
describe("ObjectsGlobal select editor escaping", () => {
it("escapes option ids and names before rendering SweetAlert HTML", async () => {
const object = {
columns: {
relay_in_id: {
label: "Relay",
type: "select",
options: vi.fn().mockResolvedValue([
{
id: 'relay-1" autofocus onfocus="alert(1)',
name: '</option></select><iframe src="javascript:parent.localStorage.token"></iframe>',
},
]),
},
},
};
const html = await ObjectsGlobal.generateEditObjectFieldForm(object, "relay_in_id", null);
expect(html).not.toContain("</option></select><iframe");
expect(html).not.toContain("<iframe");
expect(html).not.toContain('value="relay-1" autofocus');
expect(html).toContain("relay-1&quot; autofocus onfocus=&quot;alert(1)");
expect(html).toContain(
"&lt;/option&gt;&lt;/select&gt;&lt;iframe src=&quot;javascript:parent.localStorage.token&quot;&gt;&lt;/iframe&gt;"
);
});
});
@@ -35,14 +35,27 @@ describe("self-serve studio managed inspector", () => {
expect(source).toContain("const normalizeVehicleTypeForm = (raw = {}) => {");
expect(source).toContain("const saveVehicleTypeProduct = async () => {");
expect(source).toContain("if (!canEditVehicleTypeProduct.value) {");
expect(source).toContain('await requestPut("/products", {');
expect(source).toContain('data-testid="studio-vehicle-type-form"');
expect(source).toContain('data-testid="studio-vehicle-type-name"');
expect(source).toContain(':disabled="!canEditVehicleTypeProduct"');
expect(source).toContain("const normalizeMachineTypeForm = (raw = {}) => {");
expect(source).toContain("const saveMachineType = async () => {");
expect(source).toContain('await requestPut("/department/selfserve/machine-types", {');
});
it("requires superuser access before saving vehicle type product catalog fields", () => {
const source = studioSource();
expect(source).toContain("const canEditVehicleTypeProduct = computed(() =>");
expect(source).toContain("Boolean(permissions.value.can_edit) && SessionUser.canAccessSuperUser()");
expect(source.indexOf("if (!canEditVehicleTypeProduct.value) {")).toBeLessThan(
source.indexOf('await requestPut("/products", {')
);
expect(source).toContain("Only superusers can update vehicle type product catalog fields.");
});
it("manages virtual hardware from selected gateway and relay binding nodes", () => {
const source = studioSource();
@@ -99,7 +99,8 @@ describe("self-serve studio task editing", () => {
expect(source).toContain('data-testid="studio-task-attachment-manager"');
expect(source).toContain('data-testid="studio-task-attachment-upload"');
expect(source).toContain('class="studio-debug-attachment-list"');
expect(source).toContain('@click.stop="openTaskAttachment(attachment)"');
expect(source).toContain('@click.stop="openTaskAttachment(attachment, data.object_id)"');
expect(source).toContain('@click.stop="openTaskAttachment(attachment, task.id)"');
expect(source).toContain("delete data.attachments");
});
+22 -2
View File
@@ -137,12 +137,32 @@ describe("useSelfServeLogic", () => {
await logic.downloadAttachment(5, 202, {
id: 202,
download_link: "https://cdn.example.test/photo.jpg",
download_link: "https://api-v2.truckwash.io/master/api/attachments/photo.jpg",
content: { other: "photo.jpg" },
});
expect(mocks.attachmentsDownload).not.toHaveBeenCalled();
expect(openSpy).toHaveBeenCalledWith("https://cdn.example.test/photo.jpg", "_blank", "noopener");
expect(openSpy).toHaveBeenCalledWith(
"https://api-v2.truckwash.io/master/api/attachments/photo.jpg",
"_blank",
"noopener"
);
});
it("does not open untrusted embedded task attachment links", async () => {
const openSpy = vi.fn();
vi.stubGlobal("window", { open: openSpy });
mocks.attachmentsDownload.mockResolvedValue({ download_link: "javascript:alert(document.domain)" });
const logic = useSelfServeLogic();
await logic.downloadAttachment(5, 202, {
id: 202,
download_link: "data:text/html,<script>alert(document.domain)</script>",
content: { other: "photo.jpg" },
});
expect(mocks.attachmentsDownload).toHaveBeenCalledWith(5, 202);
expect(openSpy).not.toHaveBeenCalled();
});
it("falls back to lane/reg summary when session summary does not include questions", async () => {