Fleet UI: Multi-package secondary UI — policy automation, setup experience, install-details hash (#49079)
This commit is contained in:
+58
@@ -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
@@ -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
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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[];
|
||||
|
||||
@@ -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
|
||||
|
||||
+15
-1
@@ -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") {
|
||||
|
||||
+10
-4
@@ -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 ? (
|
||||
|
||||
+340
@@ -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();
|
||||
});
|
||||
});
|
||||
+125
-16
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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 */
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
@@ -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"`
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user