Fleet UI: Multi-package secondary UI — policy automation, setup experience, install-details hash (#49079)

This commit is contained in:
RachelElysia
2026-07-10 11:26:20 -07:00
committed by GitHub
parent 307f9b3108
commit 2c9fa3767e
26 changed files with 947 additions and 49 deletions
@@ -7,6 +7,7 @@ import {
getDefaultSoftwareInstallHandler,
getSoftwareInstallHandlerNoOutputs,
getSoftwareInstallHandlerOnlyInstallOutput,
getSoftwareInstallHandlerWithHash,
getSoftwareInstallHandlerWithPreInstall,
getSoftwareInstallHandlerOnlyPreInstallOutput,
getSoftwareInstallResultHandlerPremiumRequired,
@@ -414,4 +415,61 @@ describe("SoftwareInstallDetailsModal", () => {
);
});
});
// The Package SHA-256 hash row is guarded on the payload's `hash_sha256`
// field. Backend hydrates it for package-backed installs; VPP / older
// results carry no hash and the row must stay out of the DOM.
describe("Package SHA-256 hash row", () => {
afterEach(() => {
mockServer.resetHandlers();
});
it("renders the label, hash, and a copy button when the install result carries hash_sha256", async () => {
mockServer.use(getSoftwareInstallHandlerWithHash);
const renderWithServer = createCustomRenderer({ withBackendMock: true });
renderWithServer(
<SoftwareInstallDetailsModal
details={baseDetails}
hostSoftware={baseHostSoftware}
onCancel={noop}
/>
);
expect(
await screen.findByText("Package SHA-256 hash:")
).toBeInTheDocument();
expect(
screen.getByText(
"e6ddb2dd089ecea38ab73ed12812df269f1447e750cf4355703340bb8aa1ad"
)
).toBeInTheDocument();
expect(
screen.getByRole("button", { name: /Copy hash to clipboard/i })
).toBeInTheDocument();
});
it("does not render the hash row when the install result has no hash_sha256", async () => {
mockServer.use(getDefaultSoftwareInstallHandler);
const renderWithServer = createCustomRenderer({ withBackendMock: true });
renderWithServer(
<SoftwareInstallDetailsModal
details={baseDetails}
hostSoftware={baseHostSoftware}
onCancel={noop}
/>
);
// Wait for the modal to finish loading (status message is a good
// anchor — it renders after the useQuery resolves).
await screen.findByText(/Fleet installed/);
expect(
screen.queryByText("Package SHA-256 hash:")
).not.toBeInTheDocument();
expect(
screen.queryByRole("button", { name: /Copy hash to clipboard/i })
).not.toBeInTheDocument();
});
});
});
@@ -27,14 +27,17 @@ import { getDisplayedSoftwareName } from "pages/SoftwarePage/helpers";
import Modal from "components/Modal";
import ModalFooter from "components/ModalFooter";
import Button from "components/buttons/Button";
import CopyButton from "components/buttons/CopyButton";
import IconStatusMessage from "components/IconStatusMessage";
import Textarea from "components/Textarea";
import DataError from "components/DataError/DataError";
import DataSet from "components/DataSet";
import DeviceUserError from "components/DeviceUserError";
import Spinner from "components/Spinner/Spinner";
import RevealButton from "components/buttons/RevealButton";
import CustomLink from "components/CustomLink";
import PremiumFeatureMessage from "components/PremiumFeatureMessage";
import TooltipTruncatedText from "components/TooltipTruncatedText";
import {
INSTALL_DETAILS_STATUS_ICONS,
@@ -452,6 +455,30 @@ export const SoftwareInstallDetailsModal = ({
canOverrideFailureWithInstalled={canOverrideFailureWithInstalled}
/>
{/* Package SHA-256 hash — backend hydrates `hash_sha256` on the
install result. Guarded so the row stays out of the DOM for
older results and VPP/App-Store paths whose payload doesn't
carry a package hash. */}
{swInstallResult?.hash_sha256 && (
<div className={`${baseClass}__hash-row`}>
<DataSet
title="Package SHA-256 hash:"
value={
<>
<TooltipTruncatedText
className={`${baseClass}__hash`}
value={swInstallResult.hash_sha256}
/>
<CopyButton
copyText={swInstallResult.hash_sha256}
ariaLabel="Copy hash to clipboard"
/>
</>
}
/>
</div>
)}
{shouldShowInventoryVersions && renderInventoryVersionsSection()}
{isInstalledByFleet &&
!overrideFailedMessageWithInstalledMessage &&
@@ -13,4 +13,17 @@
.reveal-button {
width: min-content;
}
// Hash + copy button sit inline. `min-width: 0` on the truncated text is
// load-bearing — flex items don't shrink below their content by default,
// which would push the copy button off-screen for long hashes.
&__hash-row .data-set dd {
align-items: center;
gap: $pad-xsmall;
}
&__hash {
min-width: 0;
flex: 1;
}
}
@@ -125,6 +125,12 @@ export interface IDropdownWrapper {
* aligning right to fit text on screen */
nowrapMenu?: boolean;
customNoOptionsMessage?: string;
/** Explicit accessible name for the combobox. When omitted, the resolved
* aria-label falls back to `placeholder`, then `name`, so existing call
* sites get at least a rough label without opting in. react-select does
* not infer any of these on its own; without a value here screen readers
* announce a bare "combobox". */
ariaLabel?: string;
}
const getOptionBackgroundColor = (
@@ -452,6 +458,7 @@ const DropdownWrapper = ({
variant,
nowrapMenu,
customNoOptionsMessage,
ariaLabel,
}: IDropdownWrapper) => {
const wrapperClassNames = classnames(baseClass, className, {
[`${baseClass}__table-filter`]: variant === "table-filter",
@@ -559,6 +566,11 @@ const DropdownWrapper = ({
placeholder={placeholder}
onMenuOpen={onMenuOpen}
controlShouldRenderValue={variant !== "button"} // Control doesn't change placeholder to selected value
// Resolve accessible name: explicit prop wins, otherwise fall back
// to the placeholder (usually "Select X"), otherwise the required
// `name` (often a kebab-case identifier — least readable but
// guaranteed present).
aria-label={ariaLabel ?? placeholder ?? name}
/>
</FormField>
);
+8
View File
@@ -80,6 +80,10 @@ export interface IPolicySoftwareToInstall {
display_name?: string;
software_title_id: number;
icon_url?: string | null;
/** Present when the policy pins a specific package on a multi-package
* title. Absent for VPP-backed policies. When absent the automations UI
* falls back to auto-selecting the title's first-added package. */
software_installer_id?: number;
}
// Used on the manage hosts page and other places where aggregate stats are displayed
@@ -141,6 +145,10 @@ export interface IPolicyFormData {
conditional_access_enabled?: boolean;
continuous_automations_enabled?: boolean;
software_title_id?: number | null;
/** Pins the policy to a specific package on a multi-package title. `null`
* on PATCH lets the backend fall back to the title's first-added package
* (mirrors `software_title_id`'s unset asymmetry). */
software_installer_id?: number | null;
// null for PATCH to unset - note asymmetry with GET/LIST - see IPolicy.run_script
script_id?: number | null;
labels_include_any?: string[];
+4
View File
@@ -546,6 +546,10 @@ export interface ISoftwareInstallResult {
created_at: string;
updated_at: string | null;
self_service: boolean;
/** SHA-256 of the installer package. Present when the payload was
* hydrated from a package-backed install; absent for VPP / older results
* whose backend join hasn't been extended. */
hash_sha256?: string;
}
// Script results are only install results, never uninstall
@@ -9,6 +9,7 @@ import TextCell from "components/TableContainer/DataTable/TextCell";
import SoftwareNameCell from "components/TableContainer/DataTable/SoftwareNameCell";
import Checkbox from "components/forms/fields/Checkbox";
import GitOpsModeTooltipWrapper from "components/GitOpsModeTooltipWrapper";
import TooltipWrapper from "components/TooltipWrapper";
import { SetupExperiencePlatform } from "interfaces/platform";
import AndroidLatestVersionWithTooltip from "components/MDM/AndroidLatestVersionWithTooltip";
@@ -82,7 +83,20 @@ const generateTableConfig = (
sortType: "caseInsensitive",
},
{
Header: "Version",
id: "version",
Header: () => (
<TooltipWrapper
tipContent={
<>
For custom packages, the first
<br />
added version will be installed.
</>
}
>
Version
</TooltipWrapper>
),
disableSortBy: true,
Cell: (cellProps: ITableStringCellProps) => {
if (platform === "android") {
@@ -573,11 +573,17 @@ const LibraryItemAccordion = ({
</Button>
);
// Only FMA and App Store / Play Store rows are GitOps-locked (those
// installer types can't be managed via YAML); custom packages stay
// deletable. The `software` entity exception is honored via the wrapper.
// GitOps-lock the trash button for installer types whose mutations should
// flow through YAML rather than the UI:
// - FMA and App Store / Play Store: can't be managed via YAML in the
// ordinary sense, so UI mutations would just be reverted on next run.
// - Custom multi-package titles: the Edit modal is already visible-but-
// disabled for these in GitOps mode; delete follows the same lock so
// the row's mutation affordances stay consistent.
// Single-package custom titles keep the shipped behavior — deletable via
// UI with a GitOps banner in the delete modal.
const isAppStore = installerType === "app-store";
const lockedByGitOpsMode = isFma || isAppStore;
const lockedByGitOpsMode = isFma || isAppStore || canActivateMultiplePackages;
const renderTrashButton = () =>
lockedByGitOpsMode ? (
@@ -0,0 +1,340 @@
import React from "react";
import { screen } from "@testing-library/react";
import { createCustomRenderer } from "test/test-utils";
import createMockUser from "__mocks__/userMock";
import {
createMockSoftwareTitle,
createMockSoftwarePackage,
createMockAppStoreApp,
} from "__mocks__/softwareMock";
import { IPolicy } from "interfaces/policy";
import { ISoftwareTitle } from "interfaces/software";
import PolicyAutomationsFields, {
IPolicyAutomationsFieldsHandle,
} from "./PolicyAutomationsFields";
import useSoftwareTitles from "./hooks/useSoftwareTitles";
import useScripts from "./hooks/useScripts";
jest.mock("./hooks/useSoftwareTitles");
jest.mock("./hooks/useScripts");
jest.mock("hooks/useGitOpsMode", () => ({
__esModule: true,
default: () => ({ gitOpsModeEnabled: false }),
}));
const mockedUseSoftwareTitles = useSoftwareTitles as jest.MockedFunction<
typeof useSoftwareTitles
>;
const mockedUseScripts = useScripts as jest.MockedFunction<typeof useScripts>;
const setSoftwareTitles = (titles: ISoftwareTitle[]) => {
mockedUseSoftwareTitles.mockReturnValue({
data: {
count: titles.length,
counts_updated_at: null,
meta: { has_next_results: false, has_previous_results: false },
software_titles: titles,
},
} as ReturnType<typeof useSoftwareTitles>);
};
const emptyScriptsResponse = ({
data: {
count: 0,
scripts: [],
meta: { has_next_results: false, has_previous_results: false },
},
} as unknown) as ReturnType<typeof useScripts>;
const createMockPolicy = (overrides?: Partial<IPolicy>): IPolicy => ({
id: 1,
name: "Test policy",
query: "SELECT 1;",
description: "",
author_id: 1,
author_name: "Admin",
author_email: "admin@example.com",
resolution: "",
platform: "darwin",
team_id: 1,
created_at: "2026-01-01T00:00:00Z",
updated_at: "2026-01-01T00:00:00Z",
critical: false,
calendar_events_enabled: false,
conditional_access_enabled: false,
type: "dynamic",
...overrides,
});
// Titles used across tests
const singlePackageTitle: ISoftwareTitle = createMockSoftwareTitle({
id: 10,
name: "Single App",
source: "apps",
software_package: createMockSoftwarePackage({
installer_id: 100,
name: "single-app.pkg",
version: "1.0.0",
uploaded_at: "2026-06-01T00:00:00Z",
}),
packages: [
createMockSoftwarePackage({
installer_id: 100,
name: "single-app.pkg",
version: "1.0.0",
uploaded_at: "2026-06-01T00:00:00Z",
}),
],
});
const multiPackageTitle: ISoftwareTitle = createMockSoftwareTitle({
id: 20,
name: "Multi App",
source: "apps",
software_package: createMockSoftwarePackage({
installer_id: 200,
name: "multi-app-1.0.0.pkg",
version: "1.0.0",
uploaded_at: "2026-06-01T00:00:00Z",
}),
packages: [
createMockSoftwarePackage({
installer_id: 201,
name: "multi-app-2.0.0.pkg",
version: "2.0.0",
uploaded_at: "2026-06-15T00:00:00Z",
}),
// Out of order to prove `findFirstAddedPackage` picks by smallest id.
createMockSoftwarePackage({
installer_id: 200,
name: "multi-app-1.0.0.pkg",
version: "1.0.0",
uploaded_at: "2026-06-01T00:00:00Z",
}),
createMockSoftwarePackage({
installer_id: 202,
name: "multi-app-3.0.0.pkg",
version: "3.0.0",
uploaded_at: "2026-06-20T00:00:00Z",
}),
],
});
const vppTitle: ISoftwareTitle = createMockSoftwareTitle({
id: 30,
name: "VPP App",
source: "apps",
software_package: null,
app_store_app: createMockAppStoreApp({ version: "5.0.0" }),
packages: null,
});
const render = createCustomRenderer({
context: {
app: {
currentUser: createMockUser({ global_role: "admin" }),
isGlobalAdmin: true,
isPremiumTier: true,
},
},
});
/** Renders the field, forwarding the passed-in ref directly to the
* component's `useImperativeHandle` so tests can call
* `getAutomationsPayload()` after auto-select effects settle. Passing the
* ref directly (vs copying it in a useEffect) avoids stale-closure reads:
* `useImperativeHandle` reassigns `ref.current` on every render, so the
* external `handleRef` always sees the latest closure. */
const renderWithHandle = (
policyOverrides?: Partial<IPolicy>,
handleRef?: React.MutableRefObject<IPolicyAutomationsFieldsHandle | null>
) => {
return render(
<PolicyAutomationsFields
ref={handleRef}
policy={createMockPolicy(policyOverrides)}
isGlobalPolicy={false}
teamIdForApi={1}
automationsConfig={undefined}
globalConfig={undefined}
fleetName="Test Fleet"
/>
);
};
describe("PolicyAutomationsFields — Install software row", () => {
beforeEach(() => {
mockedUseScripts.mockReturnValue(emptyScriptsResponse);
setSoftwareTitles([singlePackageTitle, multiPackageTitle, vppTitle]);
});
afterEach(() => {
jest.clearAllMocks();
});
it("does not render the Select software dropdown when Install software is off", () => {
renderWithHandle();
expect(
screen.queryByRole("combobox", { name: /Select package/i })
).not.toBeInTheDocument();
// The outer dropdown's accessible name comes from react-select's default;
// easier to check that the placeholder isn't in the DOM.
expect(screen.queryByText("Select software")).not.toBeInTheDocument();
});
it("surfaces the Select package dropdown for a multi-package title and auto-selects the first-added (smallest installer_id)", () => {
renderWithHandle({
install_software: {
name: "Multi App",
software_title_id: 20,
},
});
// Multi-package title has 3 packages — second dropdown must render, and
// its selected option should be `multi-app-1.0.0.pkg` (installer_id 200 —
// smallest even though it's not first in the packages[] array).
const selectPackage = screen.getByRole("combobox", {
name: /Select package/i,
});
expect(selectPackage).toBeInTheDocument();
expect(screen.getByText("multi-app-1.0.0.pkg")).toBeInTheDocument();
});
it("does not surface the Select package dropdown for a single-package title", () => {
renderWithHandle({
install_software: {
name: "Single App",
software_title_id: 10,
},
});
expect(
screen.queryByRole("combobox", { name: /Select package/i })
).not.toBeInTheDocument();
});
it("does not surface the Select package dropdown for a VPP / App Store title (no packages[])", () => {
renderWithHandle({
install_software: {
name: "VPP App",
software_title_id: 30,
},
});
expect(
screen.queryByRole("combobox", { name: /Select package/i })
).not.toBeInTheDocument();
});
it("surfaces the Select package dropdown at the exact 2-package threshold and preselects the first-added package", async () => {
// Pins the `packageOptions.length > 1` gate: two-package titles must
// show the picker, one-package titles must not. Guards against a
// future refactor accidentally moving the boundary to `>= 2` (same
// effective behavior) but also against `> 2` (which would silently
// hide the picker on the smallest multi-package title). Also confirms
// the auto-select effect preselects the first-added (smallest
// installer_id) — order-independent regardless of packages[] order.
setSoftwareTitles([
createMockSoftwareTitle({
id: 40,
name: "Duo App",
source: "apps",
packages: [
// Out of order to prove first-added is picked by installer_id,
// not by array position.
createMockSoftwarePackage({
installer_id: 401,
name: "duo-app-2.0.0.pkg",
version: "2.0.0",
uploaded_at: "2026-06-15T00:00:00Z",
}),
createMockSoftwarePackage({
installer_id: 400,
name: "duo-app-1.0.0.pkg",
version: "1.0.0",
uploaded_at: "2026-06-01T00:00:00Z",
}),
],
}),
]);
renderWithHandle({
install_software: {
name: "Duo App",
software_title_id: 40,
},
});
expect(
screen.getByRole("combobox", { name: /Select package/i })
).toBeInTheDocument();
// Auto-select is set by a useEffect (async post-commit); wait for the
// preselected label to appear rather than reading state synchronously.
expect(await screen.findByText("duo-app-1.0.0.pkg")).toBeInTheDocument();
});
});
describe("PolicyAutomationsFields — payload", () => {
beforeEach(() => {
mockedUseScripts.mockReturnValue(emptyScriptsResponse);
setSoftwareTitles([singlePackageTitle, multiPackageTitle, vppTitle]);
});
afterEach(() => {
jest.clearAllMocks();
});
it("carries software_installer_id (auto-selected first-added) for a multi-package title", async () => {
const handleRef: React.MutableRefObject<IPolicyAutomationsFieldsHandle | null> = {
current: null,
};
renderWithHandle(
{
install_software: {
name: "Multi App",
software_title_id: 20,
},
},
handleRef
);
// Wait for the auto-select useEffect to hydrate the second dropdown
// (visible value = first-added filename) before reading the payload —
// otherwise we're reading state from the initial commit, before the
// effect has run.
await screen.findByText("multi-app-1.0.0.pkg");
const payload = handleRef.current?.getAutomationsPayload();
expect(payload?.isValid).toBe(true);
// First-added by smallest installer_id = 200
expect(payload?.policyUpdate?.software_installer_id).toBe(200);
expect(payload?.policyUpdate?.software_title_id).toBe(20);
});
it("does not error on save for a VPP title (must-fix: previously required non-null software_installer_id even without packages[])", () => {
const handleRef: React.MutableRefObject<IPolicyAutomationsFieldsHandle | null> = {
current: null,
};
renderWithHandle(
{
install_software: {
name: "VPP App",
software_title_id: 30,
},
},
handleRef
);
const payload = handleRef.current?.getAutomationsPayload();
// Regression guard for the VPP path: validate() must NOT flag the
// missing installer_id when the selected title has no packages[]. The
// payload can still be dirty on legacy-load (form pre-fill logic); the
// point of this test is that isValid stays true so the parent can save.
expect(payload?.isValid).toBe(true);
// Backend picks the VPP install target from software_title_id; we send
// installer_id as null on the wire.
expect(payload?.policyUpdate?.software_installer_id ?? null).toBeNull();
});
});
@@ -3,6 +3,7 @@
import React, {
forwardRef,
useContext,
useEffect,
useImperativeHandle,
useMemo,
useState,
@@ -26,7 +27,9 @@ import GitOpsModeTooltipWrapper from "components/GitOpsModeTooltipWrapper";
import TooltipWrapper from "components/TooltipWrapper";
import {
findFirstAddedPackage,
generateSoftwareOptionHelpText,
generateSoftwarePackageOptionHelpText,
getTicketOrWebhookInfo,
getTicketOrWebhookLabel,
} from "pages/policies/helpers";
@@ -163,6 +166,13 @@ const PolicyAutomationsFields = forwardRef<
const [softwareTitleId, setSoftwareTitleId] = useState<number | null>(
policy.install_software?.software_title_id ?? null
);
// Pins the automation to a specific package on a multi-package title.
// When the policy payload doesn't carry `software_installer_id` (VPP
// titles never do; single-package titles didn't need it), the
// auto-select effect below resolves to first-added.
const [softwareInstallerId, setSoftwareInstallerId] = useState<
number | null
>(policy.install_software?.software_installer_id ?? null);
const [scriptId, setScriptId] = useState<number | null>(
policy.run_script?.id ?? null
);
@@ -181,6 +191,18 @@ const PolicyAutomationsFields = forwardRef<
const newErrors: IAutomationsErrors = {};
if (installSoftware && softwareTitleId === null) {
newErrors.install_software = "Please select software to install.";
} else if (
installSoftware &&
softwareTitleId !== null &&
(selectedTitlePackages?.length ?? 0) > 0 &&
softwareInstallerId === null
) {
// Only reachable when a custom title (with packages[]) is selected
// but its packages haven't hydrated yet — the auto-select effect
// resolves this as soon as the softwareTitlesData query returns.
// VPP / App Store titles carry no packages[] and legitimately have
// no installer id, so the gate above excludes them.
newErrors.install_software = "Please select a package to install.";
}
if (runScript && scriptId === null) {
newErrors.run_script = "Please select a script to run.";
@@ -198,6 +220,13 @@ const PolicyAutomationsFields = forwardRef<
};
const handleSelectSoftware = (id: number | null) => {
setSoftwareTitleId(id);
// A title change invalidates the pinned installer — reset so the
// auto-select effect can pick first-added on the new title's packages.
setSoftwareInstallerId(null);
if (id !== null) clearError("install_software");
};
const handleSelectPackage = (id: number | null) => {
setSoftwareInstallerId(id);
if (id !== null) clearError("install_software");
};
const handleSelectScript = (id: number | null) => {
@@ -226,6 +255,49 @@ const PolicyAutomationsFields = forwardRef<
[softwareTitlesData]
);
// Packages on the currently-selected title. Non-null only for custom
// multi-package titles — VPP / App Store titles carry no packages[].
const selectedTitlePackages = useMemo(() => {
if (softwareTitleId === null) return null;
const selected = softwareTitlesData?.software_titles?.find(
(t) => t.id === softwareTitleId
);
return selected?.packages ?? null;
}, [softwareTitleId, softwareTitlesData]);
const packageOptions: CustomOptionType[] = useMemo(
() =>
(selectedTitlePackages ?? []).map((pkg) => ({
label: pkg.name,
value: String(pkg.installer_id),
helpText: generateSoftwarePackageOptionHelpText(pkg),
})),
[selectedTitlePackages]
);
// Auto-select the first-added package whenever the current selection
// isn't valid for the resolved packages list — covers three cases:
// 1. Fresh title selection: installer id was reset to null in
// handleSelectSoftware; pick first-added.
// 2. Legacy policy load: hydrated with software_title_id but no
// software_installer_id (e.g., policies created before backend
// surfaced the field); resolve to first-added on the title's packages.
// 3. Stale selection: an installer id that no longer appears on the
// title's packages (rare — e.g., a race where the package was
// deleted server-side); fall back to first-added rather than saving
// a broken pin.
useEffect(() => {
if (!selectedTitlePackages || selectedTitlePackages.length === 0) return;
const stillValid =
softwareInstallerId !== null &&
selectedTitlePackages.some(
(p) => p.installer_id === softwareInstallerId
);
if (stillValid) return;
const first = findFirstAddedPackage(selectedTitlePackages);
if (first) setSoftwareInstallerId(first.installer_id);
}, [selectedTitlePackages, softwareInstallerId]);
const scriptOptions: CustomOptionType[] = useMemo(
() =>
(scriptsData?.scripts ?? []).map((s) => ({
@@ -248,6 +320,8 @@ const PolicyAutomationsFields = forwardRef<
(installSoftware !== initialInstallSoftware ||
softwareTitleId !==
(policy.install_software?.software_title_id ?? null) ||
softwareInstallerId !==
(policy.install_software?.software_installer_id ?? null) ||
runScript !== initialRunScript ||
scriptId !== (policy.run_script?.id ?? null) ||
calendarEvent !== initialCalendar ||
@@ -261,6 +335,13 @@ const PolicyAutomationsFields = forwardRef<
policyUpdate: perPolicyDirty
? {
software_title_id: installSoftware ? softwareTitleId : null,
// Send the pinned installer id when install-software is on;
// omit when unchecked so the backend can clear it. Null
// (title selected, no packages hydrated yet) is a validation
// error and shouldn't reach here.
software_installer_id: installSoftware
? softwareInstallerId
: null,
script_id: runScript ? scriptId : null,
// When the team has the feature disabled, the row is locked
// and the user can't toggle it — so we omit the field instead
@@ -310,21 +391,42 @@ const PolicyAutomationsFields = forwardRef<
onToggle: handleToggleInstallSoftware,
isDisabled: false,
picker: installSoftware ? (
<DropdownWrapper
name="software-title"
className={`${baseClass}__row-picker`}
isDisabled={gitOpsModeEnabled}
value={
softwareOptions.find(
(o) => o.value === String(softwareTitleId ?? "")
) ?? null
}
options={softwareOptions}
placeholder="Select software"
onChange={(opt: SingleValue<CustomOptionType>) =>
handleSelectSoftware(opt ? Number(opt.value) : null)
}
/>
<div className={`${baseClass}__software-pickers`}>
<DropdownWrapper
name="software-title"
className={`${baseClass}__row-picker`}
isDisabled={gitOpsModeEnabled}
value={
softwareOptions.find(
(o) => o.value === String(softwareTitleId ?? "")
) ?? null
}
options={softwareOptions}
placeholder="Select software"
onChange={(opt: SingleValue<CustomOptionType>) =>
handleSelectSoftware(opt ? Number(opt.value) : null)
}
/>
{/* Only surfaces for multi-package titles; first-added is
auto-selected above, so this is pin-adjustment. */}
{packageOptions.length > 1 && (
<DropdownWrapper
name="software-package"
className={`${baseClass}__row-picker`}
isDisabled={gitOpsModeEnabled}
value={
packageOptions.find(
(o) => o.value === String(softwareInstallerId ?? "")
) ?? null
}
options={packageOptions}
placeholder="Select package"
onChange={(opt: SingleValue<CustomOptionType>) =>
handleSelectPackage(opt ? Number(opt.value) : null)
}
/>
)}
</div>
) : undefined,
},
{
@@ -412,7 +514,14 @@ const PolicyAutomationsFields = forwardRef<
: ""
}`}
>
<td className={`${baseClass}__row-label`}>
<td
id={
row.key === "install_software"
? "install-software-row-label"
: undefined
}
className={`${baseClass}__row-label`}
>
<GitOpsModeTooltipWrapper
renderChildren={(disableChildren) => (
<Checkbox
@@ -58,9 +58,34 @@
color: $ui-fleet-black-50;
}
&__row-label .fleet-checkbox__label {
white-space: nowrap;
}
// Install-software row hosts two side-by-side dropdowns in the trailing
// cell; drop the padding between the label and trailing cells so the
// picker pair has a little more horizontal room to breathe.
#install-software-row-label {
padding-right: 0;
// Keep the "Install software" label pinned to the top of the cell so
// its vertical position doesn't shift when the trailing cell grows
// (e.g., a second dropdown surfaces on multi-package titles, or the
// pickers stack in the schema-open narrow-viewport layout).
vertical-align: top;
.fleet-checkbox {
height: 40px; // Match height of table cell
}
}
#install-software-row-label + &__row-trailing {
padding-left: 0;
}
&__row-trailing {
text-align: right;
width: 50%;
max-width: 60%; // Fits second dropdown for multi-package
white-space: nowrap;
}
&__row-disabled-hint {
@@ -68,11 +93,30 @@
}
&__row-picker {
max-width: 300px;
// Pin to a fixed 300px so the control doesn't wobble when the selected
// option's label changes length (short vs long package name).
width: 225px;
max-width: 100%; // Guard against narrow containers
margin-left: auto;
text-align: left;
}
&__software-pickers {
display: flex;
flex-direction: row;
gap: $pad-small;
align-items: flex-end;
// The flex container fills the td (block-level), so `margin-left: auto`
// has nothing to push against. Right-align via `justify-content` and
// reset the per-picker `margin-left: auto` (from `__row-picker`, used
// when a row hosts a single picker) so the pair sits flush.
justify-content: flex-end;
> * {
margin-left: 0;
}
}
&__learn-more {
font-size: $x-small;
color: $ui-fleet-black-75;
@@ -181,4 +181,27 @@
margin-bottom: 0;
}
}
}
// Responsive tweaks that only apply when the schema sidebar is open on the
// edit-policy page that's when the automations table gets tight. Anchored
// on `:has(.side-panel-content)` so the sidebar's own render presence is
// the signal (no JS class needed). Modal-hosted PolicyAutomationsFields is
// never inside a SidePanelPage, so these rules can't accidentally target it.
.side-panel-page:has(.side-panel-content) {
// Below md, shrink the picker so both dropdowns still fit side-by-side.
@media (max-width: $break-md) {
.policy-automations-fields__row-picker {
width: 175px;
}
}
// Below the table-controls breakpoint, stack the pickers vertically
// (each keeps its natural width) since even the shrunken pair won't fit.
@media (max-width: $table-controls-break) {
.policy-automations-fields__software-pickers {
flex-direction: column;
}
}
}
+55
View File
@@ -35,6 +35,61 @@ describe("generateSoftwareOptionHelpText", () => {
expect(generateSoftwareOptionHelpText(title)).toBe("macOS (.pkg) • 1.2.3");
});
it("shows the pluralized version count when a custom title has multiple packages", () => {
// The outer "Select software" dropdown swaps the version string for a
// count on multi-package titles — the per-package picker below the
// outer dropdown carries the actual version.
const title = createMockSoftwareTitle({
source: "apps",
app_store_app: null,
software_package: createMockSoftwarePackage({
name: "TestPackage-1.2.3.pkg",
version: "1.2.3",
}),
packages: [
createMockSoftwarePackage({
installer_id: 1,
name: "TestPackage-1.2.3.pkg",
version: "1.2.3",
}),
createMockSoftwarePackage({
installer_id: 2,
name: "TestPackage-2.0.0.pkg",
version: "2.0.0",
}),
createMockSoftwarePackage({
installer_id: 3,
name: "TestPackage-3.0.0.pkg",
version: "3.0.0",
}),
],
});
expect(generateSoftwareOptionHelpText(title)).toBe(
"macOS (.pkg) • 3 versions"
);
});
it("keeps the single-version treatment when a title has exactly one package", () => {
const title = createMockSoftwareTitle({
source: "apps",
app_store_app: null,
software_package: createMockSoftwarePackage({
name: "TestPackage-1.2.3.pkg",
version: "1.2.3",
}),
packages: [
createMockSoftwarePackage({
installer_id: 1,
name: "TestPackage-1.2.3.pkg",
version: "1.2.3",
}),
],
});
expect(generateSoftwareOptionHelpText(title)).toBe("macOS (.pkg) • 1.2.3");
});
it("labels App Store (VPP) apps and uses the app_store_app version", () => {
const title = createMockSoftwareTitle({
source: "apps",
+48 -3
View File
@@ -3,10 +3,13 @@ import { Platform, PLATFORM_DISPLAY_NAMES } from "interfaces/platform";
import { TicketOrWebhookState } from "interfaces/policy";
import {
INSTALLABLE_SOURCE_PLATFORM_CONVERSION,
ISoftwarePackage,
ISoftwareTitle,
} from "interfaces/software";
import { ITeamConfig } from "interfaces/team";
import { addedFromNow } from "utilities/date_format";
import { getExtensionFromFileName } from "utilities/file/fileUtils";
import { pluralize } from "utilities/strings/stringUtils";
export interface ITicketOrWebhookInfo {
/** "webhook" or "ticket" when an "other workflow" automation is configured
@@ -54,6 +57,12 @@ export const getTicketOrWebhookLabel = (
return "Send webhook or create ticket";
};
/** Help-text shown under each option in the default "Select software" dropdown
* on the policy automations modal. Renders `platform (type) • <version>` for
* VPP / App Store and single-package custom titles, or `platform (type) •
* N versions` for multi-package custom titles. For the "Select package"
* dropdown that surfaces when a multi-package title is picked, see
* `generateSoftwarePackageOptionHelpText`. */
export const generateSoftwareOptionHelpText = (
title: ISoftwareTitle
): string => {
@@ -75,8 +84,44 @@ export const generateSoftwareOptionHelpText = (
platform && extension
? `${PLATFORM_DISPLAY_NAMES[platform]} (.${extension})`
: "";
const version = title.software_package?.version ?? "";
const separator = platformString && version ? " • " : "";
return `${platformString}${separator}${version}`;
// Multi-package custom titles show a version count ("3 versions") in the
// outer dropdown; the per-package picker below the outer dropdown carries
// the actual version + upload date. Single-package titles keep the
// existing "version string" treatment since there's nothing to count.
const packageCount = title.packages?.length ?? 0;
const versionOrCount =
packageCount > 1
? `${packageCount} ${pluralize(packageCount, "version")}`
: title.software_package?.version ?? "";
const separator = platformString && versionOrCount ? " • " : "";
return `${platformString}${separator}${versionOrCount}`;
};
/** Help-text shown under each option in the "Select package" dropdown
* that appears when a multi-package title is picked. Mirrors the Library
* row's "version • Added X ago" secondary line. For the default "Select
* software" dropdown that lists titles, see `generateSoftwareOptionHelpText`. */
export const generateSoftwarePackageOptionHelpText = (
pkg: ISoftwarePackage
): string => {
const separator = pkg.version && pkg.uploaded_at ? " • " : "";
// `addedFromNow` already prepends "Added " — do not double-wrap.
const added = pkg.uploaded_at ? addedFromNow(pkg.uploaded_at) : "";
return `${pkg.version ?? ""}${separator}${added}`;
};
/** Returns the "first-added" package on a multi-package title, defined as the
* smallest `installer_id`. The API returns `packages[]` in that order today,
* but we `Math.min` defensively so the auto-select doesn't drift if the
* response order ever changes. Returns `null` for titles with no packages
* (e.g. VPP / App Store titles). */
export const findFirstAddedPackage = (
packages: ISoftwarePackage[] | null | undefined
): ISoftwarePackage | null => {
if (!packages || packages.length === 0) return null;
return packages.reduce((first, pkg) =>
pkg.installer_id < first.installer_id ? pkg : first
);
};
@@ -13,6 +13,7 @@ import teamsAPI from "services/entities/teams";
export type IPolicyAutomationUpdate = Pick<
IPolicyFormData,
| "software_title_id"
| "software_installer_id"
| "script_id"
| "calendar_events_enabled"
| "conditional_access_enabled"
@@ -120,6 +120,7 @@ export default {
conditional_access_enabled,
continuous_automations_enabled,
software_title_id,
software_installer_id,
script_id,
labels_include_any,
labels_include_all,
@@ -140,6 +141,7 @@ export default {
conditional_access_enabled,
continuous_automations_enabled,
software_title_id,
software_installer_id,
script_id,
labels_include_any,
labels_include_all,
@@ -113,6 +113,21 @@ export const getSoftwareInstallHandlerOnlyPreInstallOutput = http.get(
}
);
// Installed, with SHA-256 hash
export const getSoftwareInstallHandlerWithHash = http.get(
baseUrl("/software/install/:install_uuid/results"),
({ params }) => {
return HttpResponse.json({
results: createMockSoftwareInstallResult({
install_uuid: params.install_uuid as string,
status: "installed",
hash_sha256:
"e6ddb2dd089ecea38ab73ed12812df269f1447e750cf4355703340bb8aa1ad",
}),
});
}
);
// ---- MDM Command Handlers ----
/** This is used for testing command results of IPA custom packages */
+1
View File
@@ -2893,6 +2893,7 @@ func (ds *Datastore) getPoliciesBySoftwareTitleIDs(
p.id AS id,
p.name AS name,
COALESCE(si.title_id, va.title_id) AS software_title_id,
p.software_installer_id AS software_installer_id,
p.type AS type
FROM policies p
LEFT JOIN software_installers si ON p.software_installer_id = si.id
+12 -2
View File
@@ -5021,6 +5021,9 @@ func testTeamPoliciesWithVPP(t *testing.T, ds *Datastore) {
automaticPolicies, err := ds.getPoliciesBySoftwareTitleIDs(ctx, []uint{team1App3.TitleID}, team1.ID)
require.NoError(t, err)
require.Len(t, automaticPolicies, 1)
// VPP-backed policies dispatch to `AppStoreApp.AutomaticInstallPolicies`
// at the title level, not via InstallerID — the field stays nil.
require.Nil(t, automaticPolicies[0].InstallerID)
policyWithVPP, err := ds.Policy(ctx, automaticPolicies[0].ID)
require.NoError(t, err)
@@ -6212,6 +6215,11 @@ func testPoliciesBySoftwareTitleID(t *testing.T, ds *Datastore) {
require.Len(t, policies, 1)
require.Equal(t, policy1.ID, policies[0].ID)
require.Equal(t, policy1.Name, policies[0].Name)
// InstallerID is the join key used by the software-titles list to
// dispatch policies to the specific package on a multi-package title;
// verify it's populated so per-package attribution works.
require.NotNil(t, policies[0].InstallerID)
require.Equal(t, installer1ID, *policies[0].InstallerID)
// software title 1 should not have any policies when filtering by team 2
policies, err = ds.getPoliciesBySoftwareTitleIDs(ctx, []uint{*installer1.TitleID}, team2.ID)
@@ -6224,6 +6232,8 @@ func testPoliciesBySoftwareTitleID(t *testing.T, ds *Datastore) {
require.Len(t, policies, 1)
require.Equal(t, policy2.ID, policies[0].ID)
require.Equal(t, policy2.Name, policies[0].Name)
require.NotNil(t, policies[0].InstallerID)
require.Equal(t, installer2ID, *policies[0].InstallerID)
// software title 2 should not have any policies when filtering by team 1
policies, err = ds.getPoliciesBySoftwareTitleIDs(ctx, []uint{*installer2.TitleID}, team1.ID)
@@ -6290,8 +6300,8 @@ func testPoliciesBySoftwareTitleID(t *testing.T, ds *Datastore) {
require.NoError(t, err)
require.Len(t, policies, 2)
expected := map[uint]fleet.AutomaticInstallPolicy{
policy3.ID: {ID: policy3.ID, Name: policy3.Name, TitleID: *installer3.TitleID, Type: fleet.PolicyTypeDynamic},
policy4.ID: {ID: policy4.ID, Name: policy4.Name, TitleID: *installer4.TitleID, Type: fleet.PolicyTypeDynamic},
policy3.ID: {ID: policy3.ID, Name: policy3.Name, TitleID: *installer3.TitleID, InstallerID: new(installer3ID), Type: fleet.PolicyTypeDynamic},
policy4.ID: {ID: policy4.ID, Name: policy4.Name, TitleID: *installer4.TitleID, InstallerID: new(installer4ID), Type: fleet.PolicyTypeDynamic},
}
for _, got := range policies {
+24 -11
View File
@@ -489,10 +489,17 @@ func (ds *Datastore) processSoftwareTitleResults(
if err != nil {
return nil, 0, nil, ctxerr.Wrap(ctx, err, "get packages for software titles")
}
// Automatic install policies are title-level for now, so attach the same set to every package.
policiesByTitle := make(map[uint][]fleet.AutomaticInstallPolicy, len(policies))
// Key policies by installer_id so each package on a multi-package
// title only shows the policies actually bound to it — not the
// aggregated title-level list. Custom-package-backed policies
// always carry a non-nil InstallerID; VPP-backed policies do not
// (they're already attached above via softwareList[i].AppStoreApp).
policiesByInstaller := make(map[uint][]fleet.AutomaticInstallPolicy)
for _, p := range policies {
policiesByTitle[p.TitleID] = append(policiesByTitle[p.TitleID], p)
if p.InstallerID == nil {
continue
}
policiesByInstaller[*p.InstallerID] = append(policiesByInstaller[*p.InstallerID], p)
}
for titleID, pkgs := range packagesByTitle {
i, ok := titleIndex[titleID]
@@ -500,7 +507,7 @@ func (ds *Datastore) processSoftwareTitleResults(
continue
}
for j := range pkgs {
pkgs[j].AutomaticInstallPolicies = policiesByTitle[titleID]
pkgs[j].AutomaticInstallPolicies = policiesByInstaller[pkgs[j].InstallerID]
}
softwareList[i].Packages = pkgs
}
@@ -588,11 +595,13 @@ func (ds *Datastore) GetSoftwarePackagesForTitles(ctx context.Context, teamID *u
const stmt = `
SELECT
si.title_id,
si.id AS installer_id,
si.filename AS name,
si.version,
si.platform,
si.self_service,
si.url AS package_url
si.url AS package_url,
si.uploaded_at
FROM
software_installers si
WHERE
@@ -600,12 +609,14 @@ WHERE
ORDER BY si.id ASC`
type packageRow struct {
TitleID uint `db:"title_id"`
Name string `db:"name"`
Version string `db:"version"`
Platform string `db:"platform"`
SelfService bool `db:"self_service"`
PackageURL *string `db:"package_url"`
TitleID uint `db:"title_id"`
InstallerID uint `db:"installer_id"`
Name string `db:"name"`
Version string `db:"version"`
Platform string `db:"platform"`
SelfService bool `db:"self_service"`
PackageURL *string `db:"package_url"`
UploadedAt time.Time `db:"uploaded_at"`
}
ret := make(map[uint][]fleet.SoftwarePackageListItem)
@@ -622,11 +633,13 @@ ORDER BY si.id ASC`
for _, r := range rows {
selfService := r.SelfService
ret[r.TitleID] = append(ret[r.TitleID], fleet.SoftwarePackageListItem{
InstallerID: r.InstallerID,
Name: r.Name,
Version: r.Version,
Platform: r.Platform,
SelfService: &selfService,
PackageURL: r.PackageURL,
UploadedAt: r.UploadedAt,
})
}
return nil
@@ -46,6 +46,7 @@ func TestSoftwareTitles(t *testing.T) {
{"UpdateAutoUpdateConfig", testUpdateAutoUpdateConfig},
{"ListSoftwareTitlesSortByDisplayName", testListSoftwareTitlesSortByDisplayName},
{"ListSoftwareTitlesMultiplePackages", testListSoftwareTitlesMultiplePackages},
{"ListSoftwareTitlesPolicyDispatchPerInstaller", testListSoftwareTitlesPolicyDispatchPerInstaller},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
@@ -941,6 +942,63 @@ func testListSoftwareTitlesMultiplePackages(t *testing.T, ds *Datastore) {
require.Equal(t, 2, title.SoftwareInstallersCount)
}
// Regression guard for the per-installer policy dispatch loop in
// ListSoftwareTitles: a policy pinned to one specific package on a
// multi-package title must only surface on that package's
// AutomaticInstallPolicies, not on every package (title-level aggregate).
func testListSoftwareTitlesPolicyDispatchPerInstaller(t *testing.T, ds *Datastore) {
ctx := context.Background()
user := test.NewUser(t, ds, "Dispatch", "dispatch@example.com", true)
team, err := ds.NewTeam(ctx, &fleet.Team{Name: "policy-dispatch-team"})
require.NoError(t, err)
mk := func(storage, filename string) *fleet.UploadSoftwareInstallerPayload {
return &fleet.UploadSoftwareInstallerPayload{
Title: "Dispatch App",
Source: "apps",
BundleIdentifier: "com.example.dispatch",
Platform: "darwin",
Extension: "pkg",
Version: "1.0",
InstallScript: "echo",
Filename: filename,
StorageID: storage,
UserID: user.ID,
ValidatedLabels: &fleet.LabelIdentsWithScope{},
TeamID: &team.ID,
}
}
installer1ID, _, err := ds.MatchOrCreateSoftwareInstaller(ctx, mk("dispatch-a", "a.pkg"))
require.NoError(t, err)
installer2ID, _, err := ds.MatchOrCreateSoftwareInstaller(ctx, mk("dispatch-b", "b.pkg"))
require.NoError(t, err)
// Pin a policy to installer 1 only. Installer 2 must NOT see it.
pol, err := ds.NewTeamPolicy(ctx, team.ID, &user.ID, fleet.PolicyPayload{
Name: "dispatch-policy",
Query: "SELECT 1;",
})
require.NoError(t, err)
pol.SoftwareInstallerID = new(installer1ID)
require.NoError(t, ds.SavePolicy(ctx, pol, false, false))
adminFilter := fleet.TeamFilter{User: &fleet.User{GlobalRole: new(fleet.RoleAdmin)}}
titles, _, _, err := ds.ListSoftwareTitles(ctx, fleet.SoftwareTitleListOptions{TeamID: &team.ID}, adminFilter)
require.NoError(t, err)
require.Len(t, titles, 1)
require.Len(t, titles[0].Packages, 2)
// packages[] is ordered by installer_id ASC — package[0] is installer 1
// (pinned), package[1] is installer 2 (not pinned).
require.Equal(t, installer1ID, titles[0].Packages[0].InstallerID)
require.Len(t, titles[0].Packages[0].AutomaticInstallPolicies, 1, "installer 1 should carry the pinned policy")
assert.Equal(t, pol.ID, titles[0].Packages[0].AutomaticInstallPolicies[0].ID)
require.Equal(t, installer2ID, titles[0].Packages[1].InstallerID)
assert.Empty(t, titles[0].Packages[1].AutomaticInstallPolicies, "installer 2 should NOT carry any policies (regression: aggregate title-level list)")
}
func testListSoftwareTitlesInstallersOnly(t *testing.T, ds *Datastore) {
ctx := context.Background()
+7
View File
@@ -591,6 +591,13 @@ type PolicySpec struct {
type PolicySoftwareTitle struct {
// SoftwareTitleID is the ID of the title associated to the policy.
SoftwareTitleID uint `json:"software_title_id" db:"title_id"`
// SoftwareInstallerID is the ID of the specific package the policy pins
// on a multi-package title. Nil for VPP-backed policies (which pin via
// vpp_apps_teams_id, not an installer). The multi-package policy
// automation UI reads this on load to reflect the user's non-default
// package choice; when nil, the UI falls back to the title's first-added
// package.
SoftwareInstallerID *uint `json:"software_installer_id,omitempty"`
// Name is the associated installer title name
// (not the package name, but the installed software title).
Name string `json:"name" db:"name"`
+14 -4
View File
@@ -813,10 +813,17 @@ func (h *HostSoftwareWithInstaller) ForMyDevicePage(token string) {
}
type AutomaticInstallPolicy struct {
ID uint `json:"id" db:"id"`
Name string `json:"name" db:"name"`
TitleID uint `json:"-" db:"software_title_id"`
Type string `json:"type" db:"type"`
ID uint `json:"id" db:"id"`
Name string `json:"name" db:"name"`
// TitleID and InstallerID are join keys used to dispatch a policy to
// the right software title / specific package on the list response.
// Neither is exposed on the wire.
TitleID uint `json:"-" db:"software_title_id"`
// InstallerID is nil for VPP-app-backed policies (they carry
// vpp_apps_teams_id instead). For custom-package-backed policies it
// points at the specific package the policy triggers install on.
InstallerID *uint `json:"-" db:"software_installer_id"`
Type string `json:"type" db:"type"`
}
type PatchPolicyData struct {
@@ -852,12 +859,15 @@ type SoftwarePackageOrApp struct {
// SoftwarePackageListItem is the trimmed list-response package shape; it omits the
// host-only last_install/last_uninstall fields that SoftwarePackageOrApp carries.
type SoftwarePackageListItem struct {
// InstallerID is the per-package id used to pin a policy to a specific package.
InstallerID uint `json:"installer_id"`
Name string `json:"name"`
AutomaticInstallPolicies []AutomaticInstallPolicy `json:"automatic_install_policies"`
Version string `json:"version"`
Platform string `json:"platform"`
SelfService *bool `json:"self_service,omitempty"`
PackageURL *string `json:"package_url"`
UploadedAt time.Time `json:"uploaded_at"`
}
func (s *SoftwarePackageOrApp) GetPlatform() string {
+15 -3
View File
@@ -197,7 +197,7 @@ func (svc *Service) SoftwareTitleByID(ctx context.Context, id uint, teamID *uint
return nil, ctxerr.Wrap(ctx, err, "get software packages")
}
if len(pkgs) > 0 {
// Display name, icon, and policies are title-level; fetch once from the first-added package.
// Display name and icon are title-level; fetch once from the first-added package.
titleMeta, err := svc.ds.GetSoftwareInstallerMetadataByTeamAndTitleID(ctx, teamID, id, true)
if err != nil && !fleet.IsNotFound(err) {
return nil, ctxerr.Wrap(ctx, err, "get software installer metadata")
@@ -213,6 +213,19 @@ func (svc *Service) SoftwareTitleByID(ctx context.Context, id uint, teamID *uint
return nil, ctxerr.Wrap(ctx, err, "get categories for software packages")
}
// Key policies by installer_id so each package on a multi-package
// title only surfaces the ones actually bound to it. VPP-backed
// policies have nil InstallerID and dispatch via AppStoreApp.
policiesByInstaller := make(map[uint][]fleet.AutomaticInstallPolicy)
if titleMeta != nil {
for _, p := range titleMeta.AutomaticInstallPolicies {
if p.InstallerID == nil {
continue
}
policiesByInstaller[*p.InstallerID] = append(policiesByInstaller[*p.InstallerID], p)
}
}
for _, pkg := range pkgs {
summary, err := svc.ds.GetSummaryHostSoftwareInstalls(ctx, pkg.InstallerID)
if err != nil {
@@ -224,9 +237,8 @@ func (svc *Service) SoftwareTitleByID(ctx context.Context, id uint, teamID *uint
if titleMeta != nil {
pkg.DisplayName = titleMeta.DisplayName
pkg.IconUrl = titleMeta.IconUrl
// Automatic install policies are title-level for now.
pkg.AutomaticInstallPolicies = titleMeta.AutomaticInstallPolicies
}
pkg.AutomaticInstallPolicies = policiesByInstaller[pkg.InstallerID]
// Populate FleetMaintainedVersions/pin/patch policy for FMA titles.
// An FMA title has a single active package, so this runs on it.
+6 -3
View File
@@ -173,9 +173,10 @@ func (svc *Service) populatePolicyInstallSoftware(ctx context.Context, p *fleet.
return ctxerr.Wrap(ctx, err, "get software installer metadata by id")
}
p.InstallSoftware = &fleet.PolicySoftwareTitle{
SoftwareTitleID: *installerMetadata.TitleID,
Name: installerMetadata.SoftwareTitle,
DisplayName: installerMetadata.DisplayName,
SoftwareTitleID: *installerMetadata.TitleID,
SoftwareInstallerID: new(installerMetadata.InstallerID),
Name: installerMetadata.SoftwareTitle,
DisplayName: installerMetadata.DisplayName,
}
return nil
} else if p.VPPAppsTeamsID != nil {
@@ -207,6 +208,8 @@ func (svc *Service) populatePolicyPatchSoftware(ctx context.Context, p *fleet.Po
if err != nil {
return ctxerr.Wrap(ctx, err, "get software installer metadata by title id")
}
// SoftwareInstallerID intentionally omitted — patch policies target FMA
// titles (single installer per title) so per-package pinning doesn't apply.
p.PatchSoftware = &fleet.PolicySoftwareTitle{
SoftwareTitleID: *installerMetadata.TitleID,
Name: installerMetadata.SoftwareTitle,
+8
View File
@@ -272,6 +272,7 @@ func TestTeamPolicyAutomationsPopulated(t *testing.T) {
ds.GetSoftwareInstallerMetadataByIDFunc = func(ctx context.Context, id uint) (*fleet.SoftwareInstaller, error) {
require.Equal(t, softwareInstallerID, id)
return &fleet.SoftwareInstaller{
InstallerID: softwareInstallerID,
TitleID: ptr.Uint(softwareInstallerTitle),
SoftwareTitle: installerSoftwareTitle,
DisplayName: installerDisplayName,
@@ -309,6 +310,10 @@ func TestTeamPolicyAutomationsPopulated(t *testing.T) {
assert.Equal(t, softwareInstallerTitle, p.InstallSoftware.SoftwareTitleID)
assert.Equal(t, installerSoftwareTitle, p.InstallSoftware.Name)
assert.Equal(t, installerDisplayName, p.InstallSoftware.DisplayName)
// SoftwareInstallerID lets the FE pre-fill the "Select package" pin
// on reload instead of always re-deriving first-added.
require.NotNil(t, p.InstallSoftware.SoftwareInstallerID, "install_software.software_installer_id should be populated")
assert.Equal(t, softwareInstallerID, *p.InstallSoftware.SoftwareInstallerID)
require.NotNil(t, p.RunScript, "run_script should be populated")
assert.Equal(t, scriptID, p.RunScript.ID)
@@ -318,6 +323,9 @@ func TestTeamPolicyAutomationsPopulated(t *testing.T) {
assert.Equal(t, patchInstallerTitleID, p.PatchSoftware.SoftwareTitleID)
assert.Equal(t, patchSoftwareTitleName, p.PatchSoftware.Name)
assert.Equal(t, patchSoftwareDisplay, p.PatchSoftware.DisplayName)
// Patch policies target FMA titles (single installer per title), so
// per-package pinning doesn't apply and the field stays nil.
assert.Nil(t, p.PatchSoftware.SoftwareInstallerID, "patch_software.software_installer_id should stay nil")
}
// requireSoftwareIconURLs verifies that install_software.icon_url is set to the