Android commands (frontend + more backend) (#46174)
<!-- Add the related story/sub-task/bug number, like Resolves #123, or remove if NA --> **Related issue:** Resolves #41683 Updated frontend for Android commands along with additional changes in the backend. Did full QA testing with test plan. # Checklist for submitter - [x] Timeouts are implemented and retries are limited to avoid infinite loops ## Testing - [x] Added/updated automated tests - [x] QA'd all new/changed functionality manually ## Database migrations - [x] Ensured the correct collation is explicitly set for character columns (`COLLATE utf8mb4_unicode_ci`). <!-- This is an auto-generated comment: release notes by coderabbit.ai --> ## Summary by CodeRabbit * **New Features** * Android MDM: added Clear passcode action, Unenroll behavior, and refined BYO vs COBO action visibility and confirmations. * Optimistic pending states and Android-specific success/error messages in Lock/Wipe/Clear flows; modals require confirmations for Android. * **Bug Fixes** * More robust clearing of stale Android device actions during re-enrollment and Pub/Sub flows to keep UI state accurate. * **Tests** * Expanded Android MDM tests for action visibility, pending states, and end-to-end state transitions. <!-- review_stack_entry_start --> [](https://app.coderabbit.ai/change-stack/fleetdm/fleet/pull/46174?utm_source=github_walkthrough&utm_medium=github&utm_campaign=change_stack) <!-- review_stack_entry_end --> <!-- end of auto-generated comment: release notes by coderabbit.ai -->
This commit is contained in:
@@ -151,7 +151,13 @@ interface IMdmMacOsSetup {
|
||||
}
|
||||
|
||||
export type HostMdmDeviceStatus = "unlocked" | "locked" | "wiped";
|
||||
export type HostMdmPendingAction = "unlock" | "lock" | "wipe" | "location" | "";
|
||||
export type HostMdmPendingAction =
|
||||
| "unlock"
|
||||
| "lock"
|
||||
| "wipe"
|
||||
| "clear_passcode"
|
||||
| "location"
|
||||
| "";
|
||||
|
||||
export interface IHostMdmData {
|
||||
encryption_key_available: boolean;
|
||||
|
||||
@@ -320,3 +320,13 @@ export const isAutomaticDeviceEnrollment = (
|
||||
enrollmentStatus === "On (automatic)"
|
||||
);
|
||||
};
|
||||
|
||||
/** Android BYO (work profile, personally-owned) enrollment. */
|
||||
export const isAndroidBYO = (enrollmentStatus: MdmEnrollmentStatus | null) => {
|
||||
return enrollmentStatus === "On (personal)";
|
||||
};
|
||||
|
||||
/** Android COBO (company-owned, fully managed) enrollment. */
|
||||
export const isAndroidCOBO = (enrollmentStatus: MdmEnrollmentStatus | null) => {
|
||||
return enrollmentStatus === "On (automatic)";
|
||||
};
|
||||
|
||||
@@ -748,6 +748,7 @@ const DeviceUserPage = ({
|
||||
renderActionsDropdown={renderActionButtons}
|
||||
deviceUser
|
||||
deviceUserHeader={pageHeader}
|
||||
hostMdmEnrollmentStatus={null}
|
||||
/>
|
||||
<TabNav className={`${baseClass}__tab-nav`}>
|
||||
<Tabs
|
||||
|
||||
+221
@@ -1538,6 +1538,227 @@ describe("Host Actions Dropdown", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("Android hosts", () => {
|
||||
it("renders Transfer + Clear passcode + Unenroll + Lock + Delete for a BYO Android host with MDM on", async () => {
|
||||
const render = createCustomRenderer({
|
||||
context: {
|
||||
app: {
|
||||
isPremiumTier: true,
|
||||
isGlobalAdmin: true,
|
||||
isAndroidMdmEnabledAndConfigured: true,
|
||||
currentUser: createMockUser(),
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const { user } = render(
|
||||
<HostActionsDropdown
|
||||
hostTeamId={null}
|
||||
onSelect={noop}
|
||||
hostStatus="online"
|
||||
hostPlatform="android"
|
||||
hostMdmEnrollmentStatus="On (personal)"
|
||||
isConnectedToFleetMdm
|
||||
hostMdmDeviceStatus="unlocked"
|
||||
hostScriptsEnabled={false}
|
||||
/>
|
||||
);
|
||||
|
||||
await user.click(screen.getByText("Actions"));
|
||||
|
||||
expect(screen.queryByText("Transfer")).toBeInTheDocument();
|
||||
expect(screen.queryByText("Lock")).toBeInTheDocument();
|
||||
expect(screen.queryByText("Clear passcode")).toBeInTheDocument();
|
||||
expect(screen.queryByText("Unenroll")).toBeInTheDocument();
|
||||
expect(screen.queryByText("Delete")).toBeInTheDocument();
|
||||
|
||||
// BYO Android: Wipe is rejected by both EE service validation and the dropdown helper.
|
||||
expect(screen.queryByText("Wipe")).not.toBeInTheDocument();
|
||||
// Android has no Fleet-side Unlock concept.
|
||||
expect(screen.queryByText("Unlock")).not.toBeInTheDocument();
|
||||
expect(screen.queryByText("Live report")).not.toBeInTheDocument();
|
||||
expect(screen.queryByText("Run script")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders Transfer + Clear passcode + Lock + Wipe + Delete for a COBO Android host with MDM on", async () => {
|
||||
const render = createCustomRenderer({
|
||||
context: {
|
||||
app: {
|
||||
isPremiumTier: true,
|
||||
isGlobalAdmin: true,
|
||||
isAndroidMdmEnabledAndConfigured: true,
|
||||
currentUser: createMockUser(),
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const { user } = render(
|
||||
<HostActionsDropdown
|
||||
hostTeamId={null}
|
||||
onSelect={noop}
|
||||
hostStatus="online"
|
||||
hostPlatform="android"
|
||||
hostMdmEnrollmentStatus="On (automatic)"
|
||||
isConnectedToFleetMdm
|
||||
hostMdmDeviceStatus="unlocked"
|
||||
hostScriptsEnabled={false}
|
||||
/>
|
||||
);
|
||||
|
||||
await user.click(screen.getByText("Actions"));
|
||||
|
||||
expect(screen.queryByText("Transfer")).toBeInTheDocument();
|
||||
expect(screen.queryByText("Lock")).toBeInTheDocument();
|
||||
expect(screen.queryByText("Clear passcode")).toBeInTheDocument();
|
||||
expect(screen.queryByText("Wipe")).toBeInTheDocument();
|
||||
expect(screen.queryByText("Delete")).toBeInTheDocument();
|
||||
|
||||
// COBO Android: Unenroll is not surfaced; the explicit Wipe option is shown instead.
|
||||
expect(screen.queryByText("Unenroll")).not.toBeInTheDocument();
|
||||
expect(screen.queryByText("Unlock")).not.toBeInTheDocument();
|
||||
expect(screen.queryByText("Live report")).not.toBeInTheDocument();
|
||||
expect(screen.queryByText("Run script")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("does NOT show Wipe for an Android host with enrollment status 'On (manual)' (Wipe requires COBO; allow-list, not negative check)", async () => {
|
||||
const render = createCustomRenderer({
|
||||
context: {
|
||||
app: {
|
||||
isPremiumTier: true,
|
||||
isGlobalAdmin: true,
|
||||
isAndroidMdmEnabledAndConfigured: true,
|
||||
currentUser: createMockUser(),
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const { user } = render(
|
||||
<HostActionsDropdown
|
||||
hostTeamId={null}
|
||||
onSelect={noop}
|
||||
hostStatus="online"
|
||||
hostPlatform="android"
|
||||
hostMdmEnrollmentStatus="On (manual)"
|
||||
isConnectedToFleetMdm
|
||||
hostMdmDeviceStatus="unlocked"
|
||||
hostScriptsEnabled={false}
|
||||
/>
|
||||
);
|
||||
|
||||
await user.click(screen.getByText("Actions"));
|
||||
|
||||
// Lock and Clear passcode are still available for any MDM-on Android host.
|
||||
expect(screen.queryByText("Lock")).toBeInTheDocument();
|
||||
expect(screen.queryByText("Clear passcode")).toBeInTheDocument();
|
||||
// Wipe must NOT appear — COBO requires "On (automatic)" or "On (company-owned)".
|
||||
expect(screen.queryByText("Wipe")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders only Transfer + Delete when Android MDM is disabled", async () => {
|
||||
const render = createCustomRenderer({
|
||||
context: {
|
||||
app: {
|
||||
isPremiumTier: true,
|
||||
isGlobalAdmin: true,
|
||||
isAndroidMdmEnabledAndConfigured: false,
|
||||
currentUser: createMockUser(),
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const { user } = render(
|
||||
<HostActionsDropdown
|
||||
hostTeamId={null}
|
||||
onSelect={noop}
|
||||
hostStatus="online"
|
||||
hostPlatform="android"
|
||||
hostMdmEnrollmentStatus="Off"
|
||||
isConnectedToFleetMdm={false}
|
||||
hostMdmDeviceStatus="unlocked"
|
||||
hostScriptsEnabled={false}
|
||||
/>
|
||||
);
|
||||
|
||||
await user.click(screen.getByText("Actions"));
|
||||
|
||||
expect(screen.queryByText("Transfer")).toBeInTheDocument();
|
||||
expect(screen.queryByText("Delete")).toBeInTheDocument();
|
||||
|
||||
expect(screen.queryByText("Lock")).not.toBeInTheDocument();
|
||||
expect(screen.queryByText("Wipe")).not.toBeInTheDocument();
|
||||
expect(screen.queryByText("Clear passcode")).not.toBeInTheDocument();
|
||||
expect(screen.queryByText("Unenroll")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
// hide Lock / Unenroll / Wipe / Clear passcode whenever any of those four is pending. The four canX helpers all gate on hostMdmDeviceStatus !== "unlocked",
|
||||
// so one table covers the matrix:
|
||||
// - which COBO/BYO arm of the helpers we exercise (enrollment status)
|
||||
// - which pending state surfaces the gate (device status)
|
||||
// For COBO, "Unenroll" is always hidden (COBO doesn't have it); the assertion list only
|
||||
// includes options that would otherwise be visible for that enrollment.
|
||||
it.each([
|
||||
{
|
||||
case: "COBO + locking",
|
||||
enrollment: "On (automatic)" as const,
|
||||
deviceStatus: "locking" as const,
|
||||
expectHidden: ["Lock", "Wipe", "Clear passcode"],
|
||||
},
|
||||
{
|
||||
case: "COBO + wiping",
|
||||
enrollment: "On (automatic)" as const,
|
||||
deviceStatus: "wiping" as const,
|
||||
expectHidden: ["Lock", "Wipe", "Clear passcode"],
|
||||
},
|
||||
{
|
||||
case: "COBO + clearing_passcode",
|
||||
enrollment: "On (automatic)" as const,
|
||||
deviceStatus: "clearing_passcode" as const,
|
||||
expectHidden: ["Lock", "Wipe", "Clear passcode"],
|
||||
},
|
||||
{
|
||||
// BYO Android Unenroll fires AMAPI WIPE under the hood, so the pending-unenroll state
|
||||
// surfaces as hostMdmDeviceStatus="wiping" with enrollment "On (personal)".
|
||||
case: "BYO + wiping (= pending unenroll)",
|
||||
enrollment: "On (personal)" as const,
|
||||
deviceStatus: "wiping" as const,
|
||||
expectHidden: ["Lock", "Clear passcode", "Unenroll"],
|
||||
},
|
||||
])(
|
||||
"hides pending-gated actions for Android when $case (#41683)",
|
||||
async ({ enrollment, deviceStatus, expectHidden }) => {
|
||||
const render = createCustomRenderer({
|
||||
context: {
|
||||
app: {
|
||||
isPremiumTier: true,
|
||||
isGlobalAdmin: true,
|
||||
isAndroidMdmEnabledAndConfigured: true,
|
||||
currentUser: createMockUser(),
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const { user } = render(
|
||||
<HostActionsDropdown
|
||||
hostTeamId={null}
|
||||
onSelect={noop}
|
||||
hostStatus="online"
|
||||
hostPlatform="android"
|
||||
hostMdmEnrollmentStatus={enrollment}
|
||||
isConnectedToFleetMdm
|
||||
hostMdmDeviceStatus={deviceStatus}
|
||||
hostScriptsEnabled={false}
|
||||
/>
|
||||
);
|
||||
|
||||
await user.click(screen.getByText("Actions"));
|
||||
|
||||
expectHidden.forEach((label) => {
|
||||
expect(screen.queryByText(label)).not.toBeInTheDocument();
|
||||
});
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
describe("personally enrolled hosts (e.g. enrollment status => On (personal)", () => {
|
||||
it("render only the Transfer and Delete options for personally enrolled ios host", async () => {
|
||||
const render = createCustomRenderer({
|
||||
|
||||
@@ -12,6 +12,8 @@ import {
|
||||
} from "interfaces/platform";
|
||||
import { isScriptSupportedPlatform } from "interfaces/script";
|
||||
import {
|
||||
isAndroidBYO,
|
||||
isAndroidCOBO,
|
||||
isAutomaticDeviceEnrollment,
|
||||
isBYODAccountDrivenUserEnrollment,
|
||||
MdmEnrollmentStatus,
|
||||
@@ -136,6 +138,8 @@ const canTransferTeam = (config: IHostActionConfigOptions) => {
|
||||
const canTurnOffMdm = (config: IHostActionConfigOptions) => {
|
||||
const {
|
||||
hostPlatform,
|
||||
hostMdmDeviceStatus,
|
||||
hostMdmEnrollmentStatus,
|
||||
isGlobalAdmin,
|
||||
isGlobalMaintainer,
|
||||
isTeamAdmin,
|
||||
@@ -145,8 +149,24 @@ const canTurnOffMdm = (config: IHostActionConfigOptions) => {
|
||||
isMacMdmEnabledAndConfigured,
|
||||
isAndroidMdmEnabledAndConfigured,
|
||||
} = config;
|
||||
// Android: Unenroll is BYO-only per Figma (#41683). COBO admins use Wipe instead.
|
||||
const isAndroidWithUnenroll =
|
||||
isAndroid(hostPlatform) &&
|
||||
isAndroidMdmEnabledAndConfigured &&
|
||||
isAndroidBYO(hostMdmEnrollmentStatus);
|
||||
|
||||
// Per Figma dev note (#41683): hide Unenroll for Android while any of Lock / Unenroll / Wipe /
|
||||
// Clear passcode is pending. Apple Unenroll continues to ignore device_status as it does today.
|
||||
if (
|
||||
isAndroidWithUnenroll &&
|
||||
hostMdmDeviceStatus &&
|
||||
hostMdmDeviceStatus !== "unlocked"
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return (
|
||||
((isAndroid(hostPlatform) && isAndroidMdmEnabledAndConfigured) ||
|
||||
(isAndroidWithUnenroll ||
|
||||
(isAppleDevice(hostPlatform) && isMacMdmEnabledAndConfigured)) &&
|
||||
isEnrolledInMdm &&
|
||||
isConnectedToFleetMdm &&
|
||||
@@ -163,6 +183,7 @@ const canLockHost = ({
|
||||
isPremiumTier,
|
||||
hostPlatform,
|
||||
isMacMdmEnabledAndConfigured,
|
||||
isAndroidMdmEnabledAndConfigured,
|
||||
isEnrolledInMdm,
|
||||
isConnectedToFleetMdm,
|
||||
isGlobalAdmin,
|
||||
@@ -188,14 +209,21 @@ const canLockHost = ({
|
||||
isMacMdmEnabledAndConfigured &&
|
||||
isEnrolledInMdm;
|
||||
|
||||
// Android hosts (both BYO and COBO) can be locked when MDM is on.
|
||||
const isLockableAndroidDevice =
|
||||
isAndroid(hostPlatform) &&
|
||||
isAndroidMdmEnabledAndConfigured &&
|
||||
isConnectedToFleetMdm &&
|
||||
isEnrolledInMdm;
|
||||
|
||||
return (
|
||||
isPremiumTier &&
|
||||
!isAndroid(hostPlatform) &&
|
||||
hostMdmDeviceStatus === "unlocked" &&
|
||||
(hostPlatform === "windows" ||
|
||||
isLinuxLike(hostPlatform) ||
|
||||
isLockableMacOSDevice ||
|
||||
isLockableIosOrIpadDevice) &&
|
||||
isLockableIosOrIpadDevice ||
|
||||
isLockableAndroidDevice) &&
|
||||
(isGlobalAdmin || isGlobalMaintainer || isTeamAdmin || isTeamMaintainer)
|
||||
);
|
||||
};
|
||||
@@ -210,6 +238,7 @@ const canWipeHost = ({
|
||||
isEnrolledInMdm,
|
||||
isMacMdmEnabledAndConfigured,
|
||||
isWindowsMdmEnabledAndConfigured,
|
||||
isAndroidMdmEnabledAndConfigured,
|
||||
hostPlatform,
|
||||
hostMdmDeviceStatus,
|
||||
hostMdmEnrollmentStatus,
|
||||
@@ -229,12 +258,22 @@ const canWipeHost = ({
|
||||
isIPadOrIPhone(hostPlatform) &&
|
||||
isBYODAccountDrivenUserEnrollment(hostMdmEnrollmentStatus);
|
||||
|
||||
// Android: Wipe is COBO-only. COBO maps to enrollment_status="On (automatic)" today (matching
|
||||
// the generated-column rule enrolled=1 AND installed_from_dep=1 AND is_personal_enrollment=0).
|
||||
// Explicit allow-list via isAndroidCOBO so unrelated statuses ("On (manual)", "Pending", "Off")
|
||||
// aren't accidentally permitted.
|
||||
const canWipeAndroid =
|
||||
isAndroid(hostPlatform) &&
|
||||
isAndroidMdmEnabledAndConfigured &&
|
||||
isConnectedToFleetMdm &&
|
||||
isEnrolledInMdm &&
|
||||
isAndroidCOBO(hostMdmEnrollmentStatus);
|
||||
|
||||
return (
|
||||
isPremiumTier &&
|
||||
!isAndroid(hostPlatform) &&
|
||||
!isAccountDrivenEnrolledIosOrIpadosDevice &&
|
||||
hostMdmDeviceStatus === "unlocked" &&
|
||||
(isLinuxLike(hostPlatform) || canWipeWindowsOrAppleOS) &&
|
||||
(isLinuxLike(hostPlatform) || canWipeWindowsOrAppleOS || canWipeAndroid) &&
|
||||
(isGlobalAdmin || isGlobalMaintainer || isTeamAdmin || isTeamMaintainer)
|
||||
);
|
||||
};
|
||||
@@ -361,6 +400,33 @@ const canClearPasscode = (config: IHostActionConfigOptions) => {
|
||||
return false;
|
||||
}
|
||||
|
||||
const isAdminOrMaintainer =
|
||||
config.isGlobalAdmin ||
|
||||
config.isGlobalMaintainer ||
|
||||
config.isTeamAdmin ||
|
||||
config.isTeamMaintainer;
|
||||
if (!isAdminOrMaintainer) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Android: per Figma dev note (#41683) hide Clear passcode whenever any of Lock / Unenroll / Wipe / Clear passcode is pending.
|
||||
if (
|
||||
isAndroid(config.hostPlatform) &&
|
||||
config.hostMdmDeviceStatus &&
|
||||
config.hostMdmDeviceStatus !== "unlocked"
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (isAndroid(config.hostPlatform)) {
|
||||
return (
|
||||
config.isAndroidMdmEnabledAndConfigured &&
|
||||
config.isEnrolledInMdm &&
|
||||
!!config.isConnectedToFleetMdm
|
||||
);
|
||||
}
|
||||
|
||||
// iOS / iPadOS — existing behavior unchanged.
|
||||
if (!isIPadOrIPhone(config.hostPlatform)) {
|
||||
return false;
|
||||
}
|
||||
@@ -385,12 +451,7 @@ const canClearPasscode = (config: IHostActionConfigOptions) => {
|
||||
return false;
|
||||
}
|
||||
|
||||
return (
|
||||
config.isGlobalAdmin ||
|
||||
config.isGlobalMaintainer ||
|
||||
config.isTeamAdmin ||
|
||||
config.isTeamMaintainer
|
||||
);
|
||||
return true;
|
||||
};
|
||||
|
||||
const canRunScript = ({
|
||||
@@ -703,6 +764,7 @@ const modifyOptions = (
|
||||
clearPasscodeOption.tooltipContent =
|
||||
"Clear passcode is unavailable while host is pending wipe.";
|
||||
}
|
||||
|
||||
disableOptions(optionsToDisable);
|
||||
formatTurnOffOptionLabel(options, hostPlatform);
|
||||
return options;
|
||||
|
||||
@@ -37,7 +37,10 @@ import {
|
||||
IHostCertificate,
|
||||
CERTIFICATES_DEFAULT_SORT,
|
||||
} from "interfaces/certificates";
|
||||
import { FLEET_FILEVAULT_PROFILE_DISPLAY_NAME } from "interfaces/mdm";
|
||||
import {
|
||||
FLEET_FILEVAULT_PROFILE_DISPLAY_NAME,
|
||||
isAndroidBYO,
|
||||
} from "interfaces/mdm";
|
||||
import { ICommand } from "interfaces/command";
|
||||
|
||||
import { normalizeEmptyValues, wrapFleetHelper } from "utilities/helpers";
|
||||
@@ -1432,7 +1435,7 @@ const HostDetailsPage = ({
|
||||
onRefetchHost={onRefetchHost}
|
||||
renderActionsDropdown={renderActionsDropdown}
|
||||
hostMdmDeviceStatus={hostMdmDeviceStatus}
|
||||
hostMdmEnrollmentStatus={host.mdm?.enrollment_status || undefined}
|
||||
hostMdmEnrollmentStatus={host.mdm?.enrollment_status ?? null}
|
||||
/>
|
||||
</div>
|
||||
<TabNav className={`${baseClass}__tab-nav`}>
|
||||
@@ -1721,6 +1724,16 @@ const HostDetailsPage = ({
|
||||
hostName={host.display_name}
|
||||
enrollmentStatus={host.mdm.enrollment_status}
|
||||
onClose={toggleUnenrollMdmModal}
|
||||
onSuccess={() => {
|
||||
// Android BYO unenroll fires an AMAPI WIPE work-profile-only command, which the backend tracks via wipe_ref / device_status="wiping".
|
||||
// Optimistically flip the device state so the "Unenroll pending" badge shows immediately instead of waiting for the next host refetch.
|
||||
if (
|
||||
isAndroid(host.platform) &&
|
||||
isAndroidBYO(host.mdm.enrollment_status)
|
||||
) {
|
||||
setHostMdmDeviceState("wiping");
|
||||
}
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{showDiskEncryptionModal && host && (
|
||||
@@ -1870,6 +1883,7 @@ const HostDetailsPage = ({
|
||||
<WipeModal
|
||||
id={host.id}
|
||||
hostName={host.display_name}
|
||||
hostPlatform={host.platform}
|
||||
isWindowsHost={isWindowsHost}
|
||||
onSuccess={() => setHostMdmDeviceState("wiping")}
|
||||
onClose={() => setShowWipeModal(false)}
|
||||
@@ -1934,7 +1948,20 @@ const HostDetailsPage = ({
|
||||
/>
|
||||
)}
|
||||
{showClearPasscodeModal && (
|
||||
<ClearPasscodeModal id={host.id} onExit={toggleClearPasscodeModal} />
|
||||
<ClearPasscodeModal
|
||||
id={host.id}
|
||||
hostName={host.display_name}
|
||||
hostPlatform={host.platform}
|
||||
hostMdmEnrollmentStatus={host.mdm.enrollment_status}
|
||||
onExit={toggleClearPasscodeModal}
|
||||
onSuccess={() => {
|
||||
// Android: flip device_status to "clearing_passcode" so the badge appears.
|
||||
// Apple follow up: #46286
|
||||
if (isAndroid(host.platform)) {
|
||||
setHostMdmDeviceState("clearing_passcode");
|
||||
}
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
|
||||
+54
-16
@@ -5,17 +5,36 @@ import hostAPI from "services/entities/hosts";
|
||||
|
||||
import Modal from "components/Modal";
|
||||
import Button from "components/buttons/Button";
|
||||
import Checkbox from "components/forms/fields/Checkbox";
|
||||
import { isAndroid } from "interfaces/platform";
|
||||
import { MdmEnrollmentStatus } from "interfaces/mdm";
|
||||
|
||||
const baseClass = "clear-passcode-modal";
|
||||
|
||||
interface IClearPasscodeModalProps {
|
||||
id: number;
|
||||
hostName: string;
|
||||
hostPlatform: string;
|
||||
hostMdmEnrollmentStatus?: MdmEnrollmentStatus | null;
|
||||
onExit: () => void;
|
||||
onSuccess?: () => void;
|
||||
}
|
||||
|
||||
const ClearPasscodeModal = ({ id, onExit }: IClearPasscodeModalProps) => {
|
||||
const ClearPasscodeModal = ({
|
||||
id,
|
||||
hostName,
|
||||
hostPlatform,
|
||||
hostMdmEnrollmentStatus,
|
||||
onExit,
|
||||
onSuccess,
|
||||
}: IClearPasscodeModalProps) => {
|
||||
const { renderFlash } = useContext(NotificationContext);
|
||||
const [isClearingPasscode, setIsClearingPasscode] = React.useState(false);
|
||||
const [confirmChecked, setConfirmChecked] = React.useState(false);
|
||||
|
||||
const isAndroidHost = isAndroid(hostPlatform);
|
||||
const isAndroidBYO =
|
||||
isAndroidHost && hostMdmEnrollmentStatus === "On (personal)";
|
||||
|
||||
const onClearPasscode = async () => {
|
||||
setIsClearingPasscode(true);
|
||||
@@ -25,6 +44,7 @@ const ClearPasscodeModal = ({ id, onExit }: IClearPasscodeModalProps) => {
|
||||
"success",
|
||||
"Successfully sent request to clear passcode on this host."
|
||||
);
|
||||
onSuccess?.();
|
||||
} catch (e) {
|
||||
renderFlash(
|
||||
"error",
|
||||
@@ -36,7 +56,18 @@ const ClearPasscodeModal = ({ id, onExit }: IClearPasscodeModalProps) => {
|
||||
}
|
||||
};
|
||||
|
||||
const renderModalContent = () => {
|
||||
const renderBody = () => {
|
||||
if (isAndroidBYO) {
|
||||
return <p>This only clears the work profile passcode.</p>;
|
||||
}
|
||||
if (isAndroidHost) {
|
||||
return (
|
||||
<p>
|
||||
This will clear the host passcode. The user can unlock the device
|
||||
without entering a passcode.
|
||||
</p>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<p>
|
||||
This will remove the current passcode and allow anyone with physical
|
||||
@@ -45,32 +76,39 @@ const ClearPasscodeModal = ({ id, onExit }: IClearPasscodeModalProps) => {
|
||||
);
|
||||
};
|
||||
|
||||
const renderModalButtons = () => {
|
||||
return (
|
||||
<>
|
||||
return (
|
||||
<Modal className={baseClass} title="Clear passcode" onExit={onExit}>
|
||||
<div className={`${baseClass}__modal-content`}>
|
||||
{renderBody()}
|
||||
<div className={`${baseClass}__confirm-message`}>
|
||||
<span>
|
||||
<b>Please check to confirm:</b>
|
||||
</span>
|
||||
<Checkbox
|
||||
wrapperClassName={`${baseClass}__clear-checkbox`}
|
||||
value={confirmChecked}
|
||||
onChange={(value: boolean) => setConfirmChecked(value)}
|
||||
>
|
||||
I wish to clear the passcode for <b>{hostName}</b>
|
||||
</Checkbox>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="modal-cta-wrap">
|
||||
<Button
|
||||
type="button"
|
||||
onClick={onClearPasscode}
|
||||
className="clear-passcode-loading"
|
||||
variant="alert"
|
||||
isLoading={isClearingPasscode}
|
||||
disabled={!confirmChecked}
|
||||
>
|
||||
Clear Passcode
|
||||
Clear passcode
|
||||
</Button>
|
||||
<Button onClick={onExit} variant="inverse-alert">
|
||||
Cancel
|
||||
</Button>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal className={baseClass} title="Clear passcode" onExit={onExit}>
|
||||
<div className={`${baseClass}__modal-content`}>
|
||||
{renderModalContent()}
|
||||
</div>
|
||||
|
||||
<div className="modal-cta-wrap">{renderModalButtons()}</div>
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -6,7 +6,7 @@ import PATHS from "router/paths";
|
||||
import { NotificationContext } from "context/notification";
|
||||
import { getErrorReason } from "interfaces/errors";
|
||||
import hostAPI from "services/entities/hosts";
|
||||
import { isIPadOrIPhone } from "interfaces/platform";
|
||||
import { isAndroid, isIPadOrIPhone } from "interfaces/platform";
|
||||
|
||||
import Modal from "components/Modal";
|
||||
import Button from "components/buttons/Button";
|
||||
@@ -66,14 +66,28 @@ const LockModal = ({
|
||||
const [lockChecked, setLockChecked] = React.useState(false);
|
||||
const [isLocking, setIsLocking] = React.useState(false);
|
||||
|
||||
const isAndroidHost = isAndroid(platform);
|
||||
|
||||
const onLock = async () => {
|
||||
setIsLocking(true);
|
||||
try {
|
||||
await hostAPI.lockHost(id);
|
||||
onSuccess();
|
||||
renderFlash("success", "Locking host or will lock when it comes online.");
|
||||
renderFlash(
|
||||
"success",
|
||||
isAndroidHost
|
||||
? "Successfully sent request to lock this host."
|
||||
: "Locking host or will lock when it comes online."
|
||||
);
|
||||
} catch (e) {
|
||||
renderFlash("error", getErrorReason(e));
|
||||
const errorReason = getErrorReason(e);
|
||||
renderFlash(
|
||||
"error",
|
||||
isAndroidHost
|
||||
? errorReason ||
|
||||
"Couldn't send request to lock this host. Please try again."
|
||||
: errorReason
|
||||
);
|
||||
}
|
||||
setIsLocking(false);
|
||||
};
|
||||
@@ -106,6 +120,15 @@ const LockModal = ({
|
||||
);
|
||||
}
|
||||
|
||||
if (isAndroid(platform)) {
|
||||
return (
|
||||
<p>
|
||||
Locking will enforce the host lock screen and require the user to
|
||||
enter their password/PIN to regain access.
|
||||
</p>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<p>Lock a host when it needs to be returned to your organization.</p>
|
||||
|
||||
+6
@@ -22,6 +22,10 @@ interface IUnenrollMdmModalProps {
|
||||
hostName: string;
|
||||
enrollmentStatus: MdmEnrollmentStatus | null;
|
||||
onClose: () => void;
|
||||
/** Fires once the unenroll request returns 2xx, before onClose. The parent uses this to
|
||||
* optimistically flip the host's MDM device state so pending badges (e.g. Android BYO
|
||||
* "Unenroll pending") appear without waiting for the next refetch. */
|
||||
onSuccess: () => void;
|
||||
}
|
||||
|
||||
const UnenrollMdmModal = ({
|
||||
@@ -30,6 +34,7 @@ const UnenrollMdmModal = ({
|
||||
hostName,
|
||||
enrollmentStatus,
|
||||
onClose,
|
||||
onSuccess,
|
||||
}: IUnenrollMdmModalProps) => {
|
||||
const [requestState, setRequestState] = useState<
|
||||
undefined | "unenrolling" | "error"
|
||||
@@ -53,6 +58,7 @@ const UnenrollMdmModal = ({
|
||||
</>
|
||||
);
|
||||
renderFlash("success", successMessage);
|
||||
onSuccess();
|
||||
onClose();
|
||||
} catch (unenrollMdmError: unknown) {
|
||||
const errorMessage =
|
||||
|
||||
@@ -7,12 +7,14 @@ import Modal from "components/Modal";
|
||||
import Button from "components/buttons/Button";
|
||||
import Checkbox from "components/forms/fields/Checkbox";
|
||||
import { NotificationContext } from "context/notification";
|
||||
import { isAndroid } from "interfaces/platform";
|
||||
|
||||
const baseClass = "wipe-modal";
|
||||
|
||||
interface IWipeModalProps {
|
||||
id: number;
|
||||
hostName: string;
|
||||
hostPlatform: string;
|
||||
isWindowsHost: boolean;
|
||||
onSuccess: () => void;
|
||||
onClose: () => void;
|
||||
@@ -21,6 +23,7 @@ interface IWipeModalProps {
|
||||
const WipeModal = ({
|
||||
id,
|
||||
hostName,
|
||||
hostPlatform,
|
||||
isWindowsHost,
|
||||
onSuccess,
|
||||
onClose,
|
||||
@@ -28,6 +31,7 @@ const WipeModal = ({
|
||||
const { renderFlash } = useContext(NotificationContext);
|
||||
const [lockChecked, setLockChecked] = React.useState(false);
|
||||
const [isWiping, setIsWiping] = React.useState(false);
|
||||
const isAndroidHost = isAndroid(hostPlatform);
|
||||
|
||||
const onWipe = async () => {
|
||||
setIsWiping(true);
|
||||
@@ -36,10 +40,19 @@ const WipeModal = ({
|
||||
onSuccess();
|
||||
renderFlash(
|
||||
"success",
|
||||
"Wiping host or will wipe when the host comes online."
|
||||
isAndroidHost
|
||||
? "Successfully sent request to wipe this host."
|
||||
: "Wiping host or will wipe when the host comes online."
|
||||
);
|
||||
} catch (e) {
|
||||
renderFlash("error", getErrorReason(e));
|
||||
const errorReason = getErrorReason(e);
|
||||
renderFlash(
|
||||
"error",
|
||||
isAndroidHost
|
||||
? errorReason ||
|
||||
"Couldn't send request to wipe this host. Please try again."
|
||||
: errorReason
|
||||
);
|
||||
}
|
||||
onClose();
|
||||
setIsWiping(false);
|
||||
|
||||
@@ -22,6 +22,7 @@ describe("HostHeader", () => {
|
||||
showRefetchSpinner={false}
|
||||
onRefetchHost={jest.fn()}
|
||||
renderActionsDropdown={renderActionDropdown}
|
||||
hostMdmEnrollmentStatus={null}
|
||||
/>
|
||||
);
|
||||
expect(screen.getByText("Test Host")).toBeInTheDocument();
|
||||
@@ -36,6 +37,7 @@ describe("HostHeader", () => {
|
||||
onRefetchHost={jest.fn()}
|
||||
renderActionsDropdown={renderActionDropdown}
|
||||
deviceUser
|
||||
hostMdmEnrollmentStatus={null}
|
||||
/>
|
||||
);
|
||||
expect(screen.getByText("My device")).toBeInTheDocument();
|
||||
@@ -48,6 +50,7 @@ describe("HostHeader", () => {
|
||||
showRefetchSpinner={false}
|
||||
onRefetchHost={jest.fn()}
|
||||
renderActionsDropdown={renderActionDropdown}
|
||||
hostMdmEnrollmentStatus={null}
|
||||
/>
|
||||
);
|
||||
expect(screen.queryByText("Refetch")).not.toBeInTheDocument();
|
||||
@@ -60,6 +63,7 @@ describe("HostHeader", () => {
|
||||
showRefetchSpinner={false}
|
||||
onRefetchHost={jest.fn()}
|
||||
renderActionsDropdown={renderActionDropdown}
|
||||
hostMdmEnrollmentStatus={null}
|
||||
/>
|
||||
);
|
||||
const refetchButton = screen.getByRole("button", { name: /refetch/i });
|
||||
@@ -73,6 +77,7 @@ describe("HostHeader", () => {
|
||||
showRefetchSpinner
|
||||
onRefetchHost={jest.fn()}
|
||||
renderActionsDropdown={renderActionDropdown}
|
||||
hostMdmEnrollmentStatus={null}
|
||||
/>
|
||||
);
|
||||
expect(screen.getByText(/Fetching fresh vitals/i)).toBeInTheDocument();
|
||||
@@ -86,6 +91,7 @@ describe("HostHeader", () => {
|
||||
showRefetchSpinner={false}
|
||||
onRefetchHost={onRefetchHost}
|
||||
renderActionsDropdown={renderActionDropdown}
|
||||
hostMdmEnrollmentStatus={null}
|
||||
/>
|
||||
);
|
||||
fireEvent.click(screen.getByText("Refetch"));
|
||||
@@ -99,6 +105,7 @@ describe("HostHeader", () => {
|
||||
showRefetchSpinner={false}
|
||||
onRefetchHost={jest.fn()}
|
||||
renderActionsDropdown={renderActionDropdown}
|
||||
hostMdmEnrollmentStatus={null}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -115,6 +122,7 @@ describe("HostHeader", () => {
|
||||
onRefetchHost={jest.fn()}
|
||||
renderActionsDropdown={renderActionDropdown}
|
||||
hostMdmDeviceStatus={"locked" as HostMdmDeviceStatusUIState}
|
||||
hostMdmEnrollmentStatus={null}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -131,6 +139,7 @@ describe("HostHeader", () => {
|
||||
onRefetchHost={jest.fn()}
|
||||
renderActionsDropdown={renderActionDropdown}
|
||||
hostMdmDeviceStatus={"locked" as HostMdmDeviceStatusUIState}
|
||||
hostMdmEnrollmentStatus={null}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -138,4 +147,77 @@ describe("HostHeader", () => {
|
||||
|
||||
expect(await screen.findByText(/Host is locked/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders 'Lock pending' and 'Wiped' badges for Android hosts", () => {
|
||||
const { rerender } = renderWithSetup(
|
||||
<HostHeader
|
||||
summaryData={{ ...defaultSummaryData, platform: "android" }}
|
||||
showRefetchSpinner={false}
|
||||
onRefetchHost={jest.fn()}
|
||||
renderActionsDropdown={renderActionDropdown}
|
||||
hostMdmDeviceStatus={"locking" as HostMdmDeviceStatusUIState}
|
||||
hostMdmEnrollmentStatus={null}
|
||||
/>
|
||||
);
|
||||
expect(screen.getByText("Lock pending")).toBeInTheDocument();
|
||||
|
||||
rerender(
|
||||
<HostHeader
|
||||
summaryData={{ ...defaultSummaryData, platform: "android" }}
|
||||
showRefetchSpinner={false}
|
||||
onRefetchHost={jest.fn()}
|
||||
renderActionsDropdown={renderActionDropdown}
|
||||
hostMdmDeviceStatus={"wiped" as HostMdmDeviceStatusUIState}
|
||||
hostMdmEnrollmentStatus={null}
|
||||
/>
|
||||
);
|
||||
expect(screen.getByText("Wiped")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders 'Unenroll pending' (not 'Wipe pending') for BYO Android during pending wipe (#41683)", () => {
|
||||
// BYO Android Unenroll fires an AMAPI WIPE under the hood, so the backend surfaces this as
|
||||
// hostMdmDeviceStatus="wiping". The label is overridden in HostHeader for BYO so the badge
|
||||
// matches the action the admin took (Unenroll), not the underlying mechanism.
|
||||
render(
|
||||
<HostHeader
|
||||
summaryData={{ ...defaultSummaryData, platform: "android" }}
|
||||
showRefetchSpinner={false}
|
||||
onRefetchHost={jest.fn()}
|
||||
renderActionsDropdown={renderActionDropdown}
|
||||
hostMdmDeviceStatus={"wiping" as HostMdmDeviceStatusUIState}
|
||||
hostMdmEnrollmentStatus="On (personal)"
|
||||
/>
|
||||
);
|
||||
expect(screen.getByText("Unenroll pending")).toBeInTheDocument();
|
||||
expect(screen.queryByText("Wipe pending")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders 'Wipe pending' for COBO Android during pending wipe (#41683)", () => {
|
||||
render(
|
||||
<HostHeader
|
||||
summaryData={{ ...defaultSummaryData, platform: "android" }}
|
||||
showRefetchSpinner={false}
|
||||
onRefetchHost={jest.fn()}
|
||||
renderActionsDropdown={renderActionDropdown}
|
||||
hostMdmDeviceStatus={"wiping" as HostMdmDeviceStatusUIState}
|
||||
hostMdmEnrollmentStatus="On (automatic)"
|
||||
/>
|
||||
);
|
||||
expect(screen.getByText("Wipe pending")).toBeInTheDocument();
|
||||
expect(screen.queryByText("Unenroll pending")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders 'Clear passcode pending' badge for Android (#41683)", () => {
|
||||
render(
|
||||
<HostHeader
|
||||
summaryData={{ ...defaultSummaryData, platform: "android" }}
|
||||
showRefetchSpinner={false}
|
||||
onRefetchHost={jest.fn()}
|
||||
renderActionsDropdown={renderActionDropdown}
|
||||
hostMdmDeviceStatus={"clearing_passcode" as HostMdmDeviceStatusUIState}
|
||||
hostMdmEnrollmentStatus={null}
|
||||
/>
|
||||
);
|
||||
expect(screen.getByText("Clear passcode pending")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -9,7 +9,7 @@ import { HumanTimeDiffWithFleetLaunchCutoff } from "components/HumanTimeDiffWith
|
||||
import { DEFAULT_EMPTY_CELL_VALUE } from "utilities/constants";
|
||||
import { useCheckTruncatedElement } from "hooks/useCheckTruncatedElement";
|
||||
import TooltipWrapper from "components/TooltipWrapper";
|
||||
import { MdmEnrollmentStatus } from "interfaces/mdm";
|
||||
import { isAndroidBYO, MdmEnrollmentStatus } from "interfaces/mdm";
|
||||
|
||||
import { HostMdmDeviceStatusUIState } from "../../helpers";
|
||||
import { DEVICE_STATUS_TAGS, REFETCH_TOOLTIP_MESSAGES } from "./helpers";
|
||||
@@ -77,7 +77,7 @@ interface IHostSummaryProps {
|
||||
* Falls back to "My device" if not provided. */
|
||||
deviceUserHeader?: string;
|
||||
hostMdmDeviceStatus?: HostMdmDeviceStatusUIState;
|
||||
hostMdmEnrollmentStatus?: MdmEnrollmentStatus;
|
||||
hostMdmEnrollmentStatus: MdmEnrollmentStatus | null;
|
||||
}
|
||||
|
||||
const HostHeader = ({
|
||||
@@ -162,6 +162,23 @@ const HostHeader = ({
|
||||
|
||||
const tag = DEVICE_STATUS_TAGS[hostMdmDeviceStatus];
|
||||
|
||||
// BYO Android Unenroll fires an AMAPI WIPE under the hood (work-profile-only), so the backend tracks it via wipe_ref and surfaces
|
||||
// device_status="wiping". The admin clicked Unenroll, not Wipe, so override both the badge label and the tooltip copy here so they
|
||||
// describe the action the admin actually took.
|
||||
const isAndroidBYOWipe =
|
||||
isAndroid(platform) &&
|
||||
hostMdmDeviceStatus === "wiping" &&
|
||||
isAndroidBYO(hostMdmEnrollmentStatus);
|
||||
const title = isAndroidBYOWipe ? "Unenroll pending" : tag.title;
|
||||
const tipContent = isAndroidBYOWipe ? (
|
||||
<>
|
||||
Host will unenroll when it comes online. If the host is online, it will
|
||||
unenroll the next time it checks in to Fleet.
|
||||
</>
|
||||
) : (
|
||||
tag.generateTooltip(platform)
|
||||
);
|
||||
|
||||
const classNames = classnames(
|
||||
`${baseClass}__device-status-tag`,
|
||||
tag.tagType
|
||||
@@ -170,13 +187,13 @@ const HostHeader = ({
|
||||
return (
|
||||
<>
|
||||
<TooltipWrapper
|
||||
tipContent={tag.generateTooltip(platform)}
|
||||
tipContent={tipContent}
|
||||
position="top"
|
||||
underline={false}
|
||||
showArrow
|
||||
className={`${baseClass}__device-status-tag-wrapper`}
|
||||
>
|
||||
<span className={classNames}>{tag.title}</span>
|
||||
<span className={classNames}>{title}</span>
|
||||
</TooltipWrapper>
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -112,6 +112,12 @@ export const DEVICE_STATUS_TAGS: DeviceStatusTagConfig = {
|
||||
generateTooltip: () =>
|
||||
"Host will wipe when it comes online. If the host is online, it will wipe the next time it checks in to Fleet.",
|
||||
},
|
||||
clearing_passcode: {
|
||||
title: "Clear passcode pending",
|
||||
tagType: "warning",
|
||||
generateTooltip: () =>
|
||||
"Passcode will clear when the host comes online. If the host is online, it will clear the next time it checks in to Fleet.",
|
||||
},
|
||||
};
|
||||
|
||||
// We exclude "unlocked" as we dont display a tooltip for it.
|
||||
@@ -155,4 +161,9 @@ export const REFETCH_TOOLTIP_MESSAGES: Record<
|
||||
You can't fetch data from <br /> a wiped host.
|
||||
</>
|
||||
),
|
||||
clearing_passcode: (
|
||||
<>
|
||||
You can't fetch data from <br /> a host that is clearing passcode.
|
||||
</>
|
||||
),
|
||||
} as const;
|
||||
|
||||
@@ -97,6 +97,7 @@ export type HostMdmDeviceStatusUIState =
|
||||
| "locking"
|
||||
| "wiped"
|
||||
| "wiping"
|
||||
| "clearing_passcode"
|
||||
| "locating";
|
||||
|
||||
// Exclude the empty string from HostPendingAction as that doesn't represent a
|
||||
@@ -111,13 +112,19 @@ const API_TO_UI_DEVICE_STATUS_MAP: Record<
|
||||
lock: "locking",
|
||||
wiped: "wiped",
|
||||
wipe: "wiping",
|
||||
clear_passcode: "clearing_passcode",
|
||||
/** When device_status is "locked" and pending_action is "location", show "locating",
|
||||
* device_status is "unlocked" and pending_action is "location" is still "locking"
|
||||
*/
|
||||
location: "locating",
|
||||
};
|
||||
|
||||
const deviceUpdatingStates = ["unlocking", "locking", "wiping"] as const;
|
||||
const deviceUpdatingStates = [
|
||||
"unlocking",
|
||||
"locking",
|
||||
"wiping",
|
||||
"clearing_passcode",
|
||||
] as const;
|
||||
|
||||
/**
|
||||
* Gets the current UI state for the host device status. This helps us know what
|
||||
|
||||
@@ -483,9 +483,10 @@ UPDATE host_mdm
|
||||
func (ds *Datastore) SetAndroidHostUnenrolled(ctx context.Context, hostID uint) (bool, error) {
|
||||
var rows int64
|
||||
err := ds.withTx(ctx, func(tx sqlx.ExtContext) error {
|
||||
// installed_from_dep is also cleared because Android has no DEP/ABM equivalent (no "Pending" state).
|
||||
result, err := tx.ExecContext(ctx, `
|
||||
UPDATE host_mdm
|
||||
SET server_url = '', mdm_id = NULL, enrolled = 0
|
||||
SET server_url = '', mdm_id = NULL, enrolled = 0, installed_from_dep = 0
|
||||
WHERE host_id = ? AND enrolled = 1`, hostID)
|
||||
if err != nil {
|
||||
return ctxerr.Wrap(ctx, err, "set host_mdm to unenrolled for android host")
|
||||
@@ -545,7 +546,7 @@ func upsertAndroidHostMDMInfoDB(ctx context.Context, tx sqlx.ExtContext, serverU
|
||||
|
||||
_, err = tx.ExecContext(ctx, fmt.Sprintf(`
|
||||
INSERT INTO host_mdm (enrolled, server_url, installed_from_dep, mdm_id, is_server, is_personal_enrollment, host_id) VALUES %s
|
||||
ON DUPLICATE KEY UPDATE enrolled = VALUES(enrolled), server_url = VALUES(server_url), mdm_id = VALUES(mdm_id), is_personal_enrollment = VALUES(is_personal_enrollment)`, strings.Join(parts, ",")), args...)
|
||||
ON DUPLICATE KEY UPDATE enrolled = VALUES(enrolled), server_url = VALUES(server_url), installed_from_dep = VALUES(installed_from_dep), mdm_id = VALUES(mdm_id), is_personal_enrollment = VALUES(is_personal_enrollment)`, strings.Join(parts, ",")), args...)
|
||||
|
||||
return ctxerr.Wrap(ctx, err, "upsert host mdm info")
|
||||
}
|
||||
@@ -949,9 +950,23 @@ func (ds *Datastore) WipeHostViaAndroidMDM(ctx context.Context, host *fleet.Host
|
||||
return ds.issueAndroidHostMDMRef(ctx, host, cmd, "wipe_ref")
|
||||
}
|
||||
|
||||
// issueAndroidHostMDMRef performs the two-write transaction shared by LockHostViaAndroidMDM and
|
||||
// WipeHostViaAndroidMDM. refColumn is hard-coded by callers (never user input) so the
|
||||
// fmt.Sprintf into the SQL stays safe. The caller is responsible for populating cmd.CommandUUID.
|
||||
// ClearPasscodeHostViaAndroidMDM inserts the RESET_PASSWORD row into mdm_android_commands and
|
||||
// upserts host_mdm_actions.clear_passcode_ref in a single transaction.
|
||||
func (ds *Datastore) ClearPasscodeHostViaAndroidMDM(ctx context.Context, host *fleet.Host, cmd *android.MDMAndroidCommand) error {
|
||||
return ds.issueAndroidHostMDMRef(ctx, host, cmd, "clear_passcode_ref")
|
||||
}
|
||||
|
||||
// ClearHostMDMActions deletes the host_mdm_actions row for the given host. Used by the Android
|
||||
// pub/sub re-enrollment path to drop stale lock/wipe/clear-passcode refs from a previous enrollment
|
||||
// cycle.
|
||||
func (ds *Datastore) ClearHostMDMActions(ctx context.Context, hostID uint) error {
|
||||
if _, err := ds.writer(ctx).ExecContext(ctx, `DELETE FROM host_mdm_actions WHERE host_id = ?`, hostID); err != nil {
|
||||
return ctxerr.Wrap(ctx, err, "clear host_mdm_actions")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// issueAndroidHostMDMRef performs the two-write transaction. refColumn is hard-coded by callers. The caller is responsible for populating cmd.CommandUUID.
|
||||
func (ds *Datastore) issueAndroidHostMDMRef(ctx context.Context, host *fleet.Host, cmd *android.MDMAndroidCommand, refColumn string) error {
|
||||
return ds.withRetryTxx(ctx, func(tx sqlx.ExtContext) error {
|
||||
const insertCmdStmt = `
|
||||
|
||||
@@ -424,6 +424,34 @@ func testUpdateAndroidHost(t *testing.T, ds *Datastore) {
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, regressionESID, resultAfterFix.Host.UUID, "UUID should be restored after fix")
|
||||
})
|
||||
|
||||
t.Run("COBO re-enroll restores installed_from_dep", func(t *testing.T) {
|
||||
ctx := testCtx()
|
||||
cobo, err := ds.NewAndroidHost(ctx, createAndroidHost("cobo-reenroll-installed-from-dep"), true /*companyOwned*/)
|
||||
require.NoError(t, err)
|
||||
|
||||
hostMDM, err := ds.GetHostMDM(ctx, cobo.Host.ID)
|
||||
require.NoError(t, err)
|
||||
require.True(t, hostMDM.InstalledFromDep, "fresh COBO enrollment must set installed_from_dep")
|
||||
|
||||
// Simulate the unenroll cleanup that clears installed_from_dep so enrollment_status drops to "Off".
|
||||
didUnenroll, err := ds.SetAndroidHostUnenrolled(ctx, cobo.Host.ID)
|
||||
require.NoError(t, err)
|
||||
require.True(t, didUnenroll)
|
||||
hostMDM, err = ds.GetHostMDM(ctx, cobo.Host.ID)
|
||||
require.NoError(t, err)
|
||||
require.False(t, hostMDM.InstalledFromDep, "unenroll must clear installed_from_dep")
|
||||
|
||||
// Re-enroll via the same upsert path that fires from updateHost(fromEnroll=true).
|
||||
cobo.Host.UUID = "cobo-reenroll-installed-from-dep"
|
||||
require.NoError(t, ds.UpdateAndroidHost(ctx, cobo, true /*fromEnroll*/, true /*companyOwned*/))
|
||||
|
||||
hostMDM, err = ds.GetHostMDM(ctx, cobo.Host.ID)
|
||||
require.NoError(t, err)
|
||||
require.True(t, hostMDM.Enrolled)
|
||||
require.True(t, hostMDM.InstalledFromDep, "re-enroll must refresh installed_from_dep so COBO lands at 'On (automatic)'")
|
||||
require.False(t, hostMDM.IsPersonalEnrollment)
|
||||
})
|
||||
}
|
||||
|
||||
func testAndroidMDMStats(t *testing.T, ds *Datastore) {
|
||||
@@ -2057,20 +2085,29 @@ func testMDMAndroidCommandCRUD(t *testing.T, ds *Datastore) {
|
||||
})
|
||||
}
|
||||
|
||||
func testLockWipeHostViaAndroidMDM(t *testing.T, ds *Datastore) {
|
||||
ctx := t.Context()
|
||||
|
||||
host, err := ds.NewHost(ctx, &fleet.Host{
|
||||
// newBareAndroidHostForTest inserts a minimal android-platform host row. Use this for tests
|
||||
// that exercise the host_mdm_actions layer and don't need a populated android_devices row
|
||||
// (use createAndroidHost + ds.NewAndroidHost for that).
|
||||
func newBareAndroidHostForTest(t *testing.T, ds *Datastore, hostname string) *fleet.Host {
|
||||
t.Helper()
|
||||
h, err := ds.NewHost(t.Context(), &fleet.Host{
|
||||
DetailUpdatedAt: time.Now(),
|
||||
LabelUpdatedAt: time.Now(),
|
||||
PolicyUpdatedAt: time.Now(),
|
||||
SeenTime: time.Now(),
|
||||
NodeKey: ptr.String(uuid.NewString()),
|
||||
UUID: uuid.NewString(),
|
||||
Hostname: "android-lockwipe-helper-test",
|
||||
Hostname: hostname,
|
||||
Platform: "android",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
return h
|
||||
}
|
||||
|
||||
func testLockWipeHostViaAndroidMDM(t *testing.T, ds *Datastore) {
|
||||
ctx := t.Context()
|
||||
|
||||
host := newBareAndroidHostForTest(t, ds, "android-lockwipe-helper-test")
|
||||
|
||||
t.Run("Lock writes both rows atomically and reports pending", func(t *testing.T) {
|
||||
cmd := &android.MDMAndroidCommand{
|
||||
@@ -2124,6 +2161,32 @@ func testLockWipeHostViaAndroidMDM(t *testing.T, ds *Datastore) {
|
||||
require.NotNil(t, status.WipeMDMCommand)
|
||||
require.Equal(t, second.CommandUUID, status.WipeMDMCommand.CommandUUID)
|
||||
})
|
||||
t.Run("ClearPasscode writes the row and reports pending clear-passcode", func(t *testing.T) {
|
||||
// Fresh host: the parent test has Lock + Wipe pending on `host`, which would dominate
|
||||
// PendingAction() priority over clear_passcode.
|
||||
cpHost := newBareAndroidHostForTest(t, ds, "android-clear-passcode-helper-test")
|
||||
|
||||
cmd := &android.MDMAndroidCommand{
|
||||
CommandUUID: uuid.NewString(),
|
||||
HostUUID: cpHost.UUID,
|
||||
OperationName: "enterprises/E/devices/" + cpHost.UUID + "/operations/clear-passcode-1",
|
||||
CommandType: string(android.MDMAndroidCommandTypeResetPassword),
|
||||
Status: string(android.MDMAndroidCommandStatusPending),
|
||||
}
|
||||
require.NoError(t, ds.ClearPasscodeHostViaAndroidMDM(ctx, cpHost, cmd))
|
||||
|
||||
got, err := ds.GetMDMAndroidCommandByUUID(ctx, cmd.CommandUUID)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, string(android.MDMAndroidCommandTypeResetPassword), got.CommandType)
|
||||
require.Equal(t, string(android.MDMAndroidCommandStatusPending), got.Status)
|
||||
|
||||
status, err := ds.GetHostLockWipeStatus(ctx, cpHost)
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, status.ClearPasscodeMDMCommand)
|
||||
require.Equal(t, cmd.CommandUUID, status.ClearPasscodeMDMCommand.CommandUUID)
|
||||
require.True(t, status.IsPendingClearPasscode())
|
||||
require.Equal(t, fleet.PendingActionClearPasscode, status.PendingAction())
|
||||
})
|
||||
}
|
||||
|
||||
func testListHostMDMAndroidProfilesPendingInstallWithVersion(t *testing.T, ds *Datastore) {
|
||||
|
||||
@@ -13630,6 +13630,45 @@ func testGetHostLockWipeStatusAndroid(t *testing.T, ds *Datastore) {
|
||||
require.Equal(t, string(android.MDMAndroidCommandStatusError), status.LockMDMCommandResult.Status)
|
||||
require.False(t, status.IsLocked())
|
||||
require.False(t, status.IsPendingLock())
|
||||
|
||||
// Pending clear-passcode: IsPendingClearPasscode = true, device_status = clear_passcode
|
||||
// pending action so the UI hides Lock / Unenroll / Wipe / Clear passcode.
|
||||
cpHost := createEnrolledAndroidHost(t, ctx, ds, uuid.NewString(), nil)
|
||||
cpUUID := uuid.NewString()
|
||||
require.NoError(t, ds.ClearPasscodeHostViaAndroidMDM(ctx, cpHost, &android.MDMAndroidCommand{
|
||||
CommandUUID: cpUUID,
|
||||
HostUUID: cpHost.UUID,
|
||||
OperationName: "enterprises/E/devices/" + cpHost.UUID + "/operations/clear-passcode",
|
||||
CommandType: string(android.MDMAndroidCommandTypeResetPassword),
|
||||
Status: string(android.MDMAndroidCommandStatusPending),
|
||||
}))
|
||||
cpStatus, err := ds.GetHostLockWipeStatus(ctx, cpHost)
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, cpStatus.ClearPasscodeMDMCommand)
|
||||
require.Equal(t, cpUUID, cpStatus.ClearPasscodeMDMCommand.CommandUUID)
|
||||
require.Nil(t, cpStatus.ClearPasscodeMDMCommandResult)
|
||||
require.True(t, cpStatus.IsPendingClearPasscode())
|
||||
require.Equal(t, fleet.PendingActionClearPasscode, cpStatus.PendingAction())
|
||||
|
||||
// After Pub/Sub ack: result populated, IsPendingClearPasscode = false, PendingAction = none.
|
||||
require.NoError(t, ds.UpdateMDMAndroidCommandStatus(ctx, cpUUID,
|
||||
string(android.MDMAndroidCommandStatusAcknowledged), nil, nil))
|
||||
cpStatus, err = ds.GetHostLockWipeStatus(ctx, cpHost)
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, cpStatus.ClearPasscodeMDMCommandResult)
|
||||
require.Equal(t, string(android.MDMAndroidCommandStatusAcknowledged),
|
||||
cpStatus.ClearPasscodeMDMCommandResult.Status)
|
||||
require.False(t, cpStatus.IsPendingClearPasscode())
|
||||
require.Equal(t, fleet.PendingActionNone, cpStatus.PendingAction())
|
||||
|
||||
// ClearHostMDMActions drops the row entirely (used by the Android re-enrollment path so
|
||||
// stale lock/wipe/clear-passcode state from a previous enrollment cycle does not bleed in).
|
||||
require.NoError(t, ds.ClearHostMDMActions(ctx, cpHost.ID))
|
||||
cpStatus, err = ds.GetHostLockWipeStatus(ctx, cpHost)
|
||||
require.NoError(t, err)
|
||||
require.Nil(t, cpStatus.ClearPasscodeMDMCommand)
|
||||
require.Equal(t, fleet.DeviceStatusUnlocked, cpStatus.DeviceStatus())
|
||||
require.Equal(t, fleet.PendingActionNone, cpStatus.PendingAction())
|
||||
}
|
||||
|
||||
// testGetHostsLockWipeStatusBatchAndroidMultiHost exercises GetHostsLockWipeStatusBatch with
|
||||
|
||||
+33
@@ -0,0 +1,33 @@
|
||||
package tables
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"fmt"
|
||||
)
|
||||
|
||||
func init() {
|
||||
MigrationClient.AddMigration(Up_20260528211626, Down_20260528211626)
|
||||
}
|
||||
|
||||
// Up_20260528163657 adds clear_passcode_ref to host_mdm_actions, mirroring lock_ref and wipe_ref. For Android hosts the column
|
||||
// points at mdm_android_commands.command_uuid for a RESET_PASSWORD command and is the signal
|
||||
// HostLockWipeStatus.IsPendingClearPasscode reads to flip a host into the "clearing passcode" device status while the AMAPI
|
||||
// command is in flight.
|
||||
//
|
||||
// Other platforms keep this column NULL. Story to add Apple support: #46286
|
||||
func Up_20260528211626(tx *sql.Tx) error {
|
||||
if columnExists(tx, "host_mdm_actions", "clear_passcode_ref") {
|
||||
return nil
|
||||
}
|
||||
if _, err := tx.Exec(`
|
||||
ALTER TABLE host_mdm_actions
|
||||
ADD COLUMN clear_passcode_ref VARCHAR(36) COLLATE utf8mb4_unicode_ci DEFAULT NULL
|
||||
`); err != nil {
|
||||
return fmt.Errorf("add clear_passcode_ref to host_mdm_actions: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func Down_20260528211626(tx *sql.Tx) error {
|
||||
return nil
|
||||
}
|
||||
File diff suppressed because one or more lines are too long
@@ -1447,11 +1447,12 @@ ON DUPLICATE KEY UPDATE
|
||||
}
|
||||
|
||||
type hostMDMActions struct {
|
||||
LockRef *string `db:"lock_ref"`
|
||||
WipeRef *string `db:"wipe_ref"`
|
||||
UnlockRef *string `db:"unlock_ref"`
|
||||
UnlockPIN *string `db:"unlock_pin"`
|
||||
FleetPlatform string `db:"fleet_platform"`
|
||||
LockRef *string `db:"lock_ref"`
|
||||
WipeRef *string `db:"wipe_ref"`
|
||||
UnlockRef *string `db:"unlock_ref"`
|
||||
UnlockPIN *string `db:"unlock_pin"`
|
||||
ClearPasscodeRef *string `db:"clear_passcode_ref"`
|
||||
FleetPlatform string `db:"fleet_platform"`
|
||||
}
|
||||
|
||||
func (ds *Datastore) GetHostLockWipeStatus(ctx context.Context, host *fleet.Host) (*fleet.HostLockWipeStatus, error) {
|
||||
@@ -1461,6 +1462,7 @@ func (ds *Datastore) GetHostLockWipeStatus(ctx context.Context, host *fleet.Host
|
||||
wipe_ref,
|
||||
unlock_ref,
|
||||
unlock_pin,
|
||||
clear_passcode_ref,
|
||||
fleet_platform
|
||||
FROM
|
||||
host_mdm_actions
|
||||
@@ -1560,29 +1562,31 @@ func (ds *Datastore) GetHostLockWipeStatus(ctx context.Context, host *fleet.Host
|
||||
}
|
||||
|
||||
case "android":
|
||||
// Android lock/wipe are AMAPI commands tracked in mdm_android_commands; lock_ref and
|
||||
// wipe_ref store the Fleet-generated command_uuid (no unlock_ref on Android).
|
||||
if mdmActions.LockRef != nil {
|
||||
cmd, cmdRes, err := ds.getHostMDMAndroidCommand(ctx, *mdmActions.LockRef)
|
||||
// Android lock/wipe/clear-passcode are AMAPI commands tracked in mdm_android_commands;
|
||||
// lock_ref / wipe_ref / clear_passcode_ref store the Fleet-generated command_uuid (no
|
||||
// unlock_ref on Android). All three fetch the same shape, so loop them.
|
||||
for _, ref := range []struct {
|
||||
label string
|
||||
refPtr *string
|
||||
cmdOut **fleet.MDMCommand
|
||||
resOut **fleet.MDMCommandResult
|
||||
}{
|
||||
{"lock", mdmActions.LockRef, &status.LockMDMCommand, &status.LockMDMCommandResult},
|
||||
{"wipe", mdmActions.WipeRef, &status.WipeMDMCommand, &status.WipeMDMCommandResult},
|
||||
{"clear-passcode", mdmActions.ClearPasscodeRef, &status.ClearPasscodeMDMCommand, &status.ClearPasscodeMDMCommandResult},
|
||||
} {
|
||||
if ref.refPtr == nil {
|
||||
continue
|
||||
}
|
||||
cmd, cmdRes, err := ds.getHostMDMAndroidCommand(ctx, *ref.refPtr)
|
||||
if err != nil && !fleet.IsNotFound(err) {
|
||||
return nil, ctxerr.Wrap(ctx, err, "get android lock reference")
|
||||
return nil, ctxerr.Wrapf(ctx, err, "get android %s reference", ref.label)
|
||||
}
|
||||
if fleet.IsNotFound(err) {
|
||||
ds.logger.ErrorContext(ctx, "orphan android lock command reference", "host_id", host.ID, "command_uuid", *mdmActions.LockRef)
|
||||
ds.logger.ErrorContext(ctx, "orphan android command reference", "ref", ref.label, "host_id", host.ID, "command_uuid", *ref.refPtr)
|
||||
}
|
||||
status.LockMDMCommand = cmd
|
||||
status.LockMDMCommandResult = cmdRes
|
||||
}
|
||||
if mdmActions.WipeRef != nil {
|
||||
cmd, cmdRes, err := ds.getHostMDMAndroidCommand(ctx, *mdmActions.WipeRef)
|
||||
if err != nil && !fleet.IsNotFound(err) {
|
||||
return nil, ctxerr.Wrap(ctx, err, "get android wipe reference")
|
||||
}
|
||||
if fleet.IsNotFound(err) {
|
||||
ds.logger.ErrorContext(ctx, "orphan android wipe command reference", "host_id", host.ID, "command_uuid", *mdmActions.WipeRef)
|
||||
}
|
||||
status.WipeMDMCommand = cmd
|
||||
status.WipeMDMCommandResult = cmdRes
|
||||
*ref.cmdOut = cmd
|
||||
*ref.resOut = cmdRes
|
||||
}
|
||||
|
||||
case "windows", "linux":
|
||||
@@ -1656,6 +1660,7 @@ func (ds *Datastore) GetHostsLockWipeStatusBatch(ctx context.Context, hosts []*f
|
||||
wipe_ref,
|
||||
unlock_ref,
|
||||
unlock_pin,
|
||||
clear_passcode_ref,
|
||||
fleet_platform
|
||||
FROM
|
||||
host_mdm_actions
|
||||
@@ -1695,11 +1700,12 @@ func (ds *Datastore) GetHostsLockWipeStatusBatch(ctx context.Context, hosts []*f
|
||||
|
||||
for _, row := range mdmActionsRows {
|
||||
mdmActionsMap[row.HostID] = &hostMDMActions{
|
||||
LockRef: row.LockRef,
|
||||
WipeRef: row.WipeRef,
|
||||
UnlockRef: row.UnlockRef,
|
||||
UnlockPIN: row.UnlockPIN,
|
||||
FleetPlatform: row.FleetPlatform,
|
||||
LockRef: row.LockRef,
|
||||
WipeRef: row.WipeRef,
|
||||
UnlockRef: row.UnlockRef,
|
||||
UnlockPIN: row.UnlockPIN,
|
||||
ClearPasscodeRef: row.ClearPasscodeRef,
|
||||
FleetPlatform: row.FleetPlatform,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1791,7 +1797,7 @@ func (ds *Datastore) GetHostsLockWipeStatusBatch(ctx context.Context, hosts []*f
|
||||
}
|
||||
|
||||
case "android":
|
||||
// Android lock/wipe are AMAPI commands in mdm_android_commands (no unlock_ref).
|
||||
// Android lock/wipe/clear-passcode are AMAPI commands in mdm_android_commands (no unlock_ref).
|
||||
if mdmActions.LockRef != nil {
|
||||
androidCommandRefs = append(androidCommandRefs, refKey{
|
||||
uuid: *mdmActions.LockRef,
|
||||
@@ -1808,6 +1814,14 @@ func (ds *Datastore) GetHostsLockWipeStatusBatch(ctx context.Context, hosts []*f
|
||||
refType: "wipe",
|
||||
})
|
||||
}
|
||||
if mdmActions.ClearPasscodeRef != nil {
|
||||
androidCommandRefs = append(androidCommandRefs, refKey{
|
||||
uuid: *mdmActions.ClearPasscodeRef,
|
||||
hostUUID: host.UUID,
|
||||
hostID: host.ID,
|
||||
refType: "clear_passcode",
|
||||
})
|
||||
}
|
||||
|
||||
case "linux":
|
||||
// Linux uses scripts for lock, unlock, and wipe
|
||||
@@ -2076,6 +2090,9 @@ func (ds *Datastore) GetHostsLockWipeStatusBatch(ctx context.Context, hosts []*f
|
||||
case "wipe":
|
||||
status.WipeMDMCommand = cmd
|
||||
status.WipeMDMCommandResult = cmdRes
|
||||
case "clear_passcode":
|
||||
status.ClearPasscodeMDMCommand = cmd
|
||||
status.ClearPasscodeMDMCommandResult = cmdRes
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3297,13 +3297,22 @@ type AndroidDatastore interface {
|
||||
LockHostViaAndroidMDM(ctx context.Context, host *Host, cmd *android.MDMAndroidCommand) error
|
||||
|
||||
// WipeHostViaAndroidMDM inserts the WIPE row into mdm_android_commands and writes the wipe_ref on host_mdm_actions in a
|
||||
// single transaction. This method is for COBO Wipe specifically (the host page surfaces it as PendingAction=wipe ->
|
||||
// DeviceStatus=wiped); the service layer must reject BYO hosts before calling. BYO unenroll also issues an AMAPI WIPE
|
||||
// (work-profile-only) but persists via NewMDMAndroidCommand without touching host_mdm_actions, because BYO unenroll
|
||||
// surfaces as the mdm_unenrolled activity, not as a wipe. The caller must populate cmd.CommandUUID and cmd.OperationName
|
||||
// before invoking.
|
||||
// single transaction. Used by COBO Wipe (which surfaces as PendingAction=wipe -> DeviceStatus=wiped) and by BYO Unenroll
|
||||
// (which sends an AMAPI WIPE work-profile-only; the wipe_ref is what HostLockWipeStatus.IsPendingWipe reads to flip
|
||||
// device_status to "wiping"). The frontend overrides the badge label to "Unenroll pending" for BYO Android based on
|
||||
// enrollment status. The caller must populate cmd.CommandUUID and cmd.OperationName before invoking.
|
||||
WipeHostViaAndroidMDM(ctx context.Context, host *Host, cmd *android.MDMAndroidCommand) error
|
||||
|
||||
// ClearPasscodeHostViaAndroidMDM inserts the RESET_PASSWORD row into mdm_android_commands and writes the
|
||||
// clear_passcode_ref on host_mdm_actions in a single transaction. The ref is what
|
||||
// HostLockWipeStatus.IsPendingClearPasscode reads to flip device_status to "clearing passcode" while the AMAPI
|
||||
// command is in flight. The caller must populate cmd.CommandUUID and cmd.OperationName before invoking.
|
||||
ClearPasscodeHostViaAndroidMDM(ctx context.Context, host *Host, cmd *android.MDMAndroidCommand) error
|
||||
|
||||
// ClearHostMDMActions deletes the host_mdm_actions row for the given host. Called on re-enrollment so stale
|
||||
// lock/wipe/clear-passcode state from a previous enrollment cycle does not bleed into the new one.
|
||||
ClearHostMDMActions(ctx context.Context, hostID uint) error
|
||||
|
||||
// UpdateHostSoftware updates the software list of a host.
|
||||
// The update consists of deleting existing entries that are not in the given `software`
|
||||
// slice, updating existing entries and inserting new entries.
|
||||
|
||||
+23
-5
@@ -664,6 +664,12 @@ type HostLockWipeStatus struct {
|
||||
// Linux uses a script for Wipe
|
||||
WipeScript *HostScriptResult
|
||||
|
||||
// Android tracks Clear passcode (RESET_PASSWORD) as a pending state via mdm_android_commands.
|
||||
// Apple's ClearPasscode lives in nano_commands and is not surfaced as a device-level pending
|
||||
// state, so these fields are Android-only today.
|
||||
ClearPasscodeMDMCommand *MDMCommand
|
||||
ClearPasscodeMDMCommandResult *MDMCommandResult
|
||||
|
||||
LocationPending bool
|
||||
}
|
||||
|
||||
@@ -700,11 +706,12 @@ func (s HostLockWipeStatus) DeviceStatus() DeviceStatus {
|
||||
type PendingDeviceAction string
|
||||
|
||||
const (
|
||||
PendingActionLock PendingDeviceAction = "lock"
|
||||
PendingActionUnlock PendingDeviceAction = "unlock"
|
||||
PendingActionWipe PendingDeviceAction = "wipe"
|
||||
PendingActionLocation PendingDeviceAction = "location"
|
||||
PendingActionNone PendingDeviceAction = ""
|
||||
PendingActionLock PendingDeviceAction = "lock"
|
||||
PendingActionUnlock PendingDeviceAction = "unlock"
|
||||
PendingActionWipe PendingDeviceAction = "wipe"
|
||||
PendingActionClearPasscode PendingDeviceAction = "clear_passcode"
|
||||
PendingActionLocation PendingDeviceAction = "location"
|
||||
PendingActionNone PendingDeviceAction = ""
|
||||
)
|
||||
|
||||
func (s HostLockWipeStatus) PendingAction() PendingDeviceAction {
|
||||
@@ -717,6 +724,8 @@ func (s HostLockWipeStatus) PendingAction() PendingDeviceAction {
|
||||
return PendingActionUnlock
|
||||
case s.IsPendingWipe():
|
||||
return PendingActionWipe
|
||||
case s.IsPendingClearPasscode():
|
||||
return PendingActionClearPasscode
|
||||
default:
|
||||
return PendingActionNone
|
||||
}
|
||||
@@ -755,6 +764,15 @@ func (s HostLockWipeStatus) IsPendingWipe() bool {
|
||||
return s.WipeMDMCommand != nil && s.WipeMDMCommandResult == nil
|
||||
}
|
||||
|
||||
// IsPendingClearPasscode reports whether a Clear Passcode is in flight.
|
||||
// Support for Apple coming in #46286
|
||||
func (s HostLockWipeStatus) IsPendingClearPasscode() bool {
|
||||
if s.HostFleetPlatform != "android" {
|
||||
return false
|
||||
}
|
||||
return s.ClearPasscodeMDMCommand != nil && s.ClearPasscodeMDMCommandResult == nil
|
||||
}
|
||||
|
||||
func (s HostLockWipeStatus) IsLocked() bool {
|
||||
// this state is regardless of pending unlock/wipe (it reports whether the
|
||||
// host is locked *now*).
|
||||
|
||||
@@ -118,6 +118,28 @@ func (svc *Service) getClientAuthenticationSecret(ctx context.Context) (string,
|
||||
return string(assets[fleet.MDMAssetAndroidFleetServerSecret].Value), nil
|
||||
}
|
||||
|
||||
// clearAndroidBYOWipeRef drops host_mdm_actions for a BYO Android host whose work-profile AMAPI WIPE just completed (DELETED
|
||||
// state from STATUS_REPORT/ENROLLMENT). On BYO the device is not factory-reset (only the work profile is removed) so
|
||||
// HostLockWipeStatus.IsWiped() must return false post-unenroll, otherwise the host page shows a misleading "Wiped" badge.
|
||||
//
|
||||
// Returns nil on success, on NotFound, and on COBO (which is a no-op). Returns a wrapped error on transient DB issues so the
|
||||
// caller can decide whether to bubble (Pub/Sub retry) or swallow (reconcile janitor continues to next host).
|
||||
func clearAndroidBYOWipeRef(ctx context.Context, ds fleet.Datastore, hostID uint) error {
|
||||
hostMDM, err := ds.GetHostMDM(ctx, hostID)
|
||||
switch {
|
||||
case fleet.IsNotFound(err):
|
||||
return nil
|
||||
case err != nil:
|
||||
return ctxerr.Wrap(ctx, err, "android byo wipe-ref cleanup: get host_mdm")
|
||||
case hostMDM == nil || !hostMDM.IsPersonalEnrollment:
|
||||
return nil
|
||||
}
|
||||
if err := ds.ClearHostMDMActions(ctx, hostID); err != nil {
|
||||
return ctxerr.Wrap(ctx, err, "android byo wipe-ref cleanup: clear host_mdm_actions")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// handlePubSubCommand processes an AMAPI COMMAND notification, which AMAPI delivers as an Operation envelope whose Name
|
||||
// is the operation_name we recorded at IssueCommand time. The envelope's Error field, when populated, indicates AMAPI
|
||||
// rejected the command (or the device rejected it); otherwise the device executed it successfully. We correlate the
|
||||
@@ -165,8 +187,15 @@ func (svc *Service) handlePubSubCommand(ctx context.Context, token string, rawDa
|
||||
return ctxerr.Wrap(ctx, err, "lookup android command by operation name")
|
||||
}
|
||||
|
||||
// Already-terminal rows: don't re-transition. AMAPI may redeliver a notification at-least-once.
|
||||
// Already-terminal rows. AMAPI may redeliver a notification at-least-once.
|
||||
// For WIPE+acknowledged specifically, still re-run handleAndroidWipeAckUnenroll so transient DB
|
||||
// failures on the original delivery recover on this retry.
|
||||
if cmd.Status != string(android.MDMAndroidCommandStatusPending) {
|
||||
if cmd.CommandType == string(android.MDMAndroidCommandTypeWipe) && cmd.Status == string(android.MDMAndroidCommandStatusAcknowledged) {
|
||||
if err := svc.handleAndroidWipeAckUnenroll(ctx, cmd); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
svc.logger.InfoContext(ctx, "android pub/sub COMMAND already terminal, ignoring",
|
||||
"operation_name", op.Name, "command_uuid", cmd.CommandUUID, "current_status", cmd.Status)
|
||||
return nil
|
||||
@@ -186,6 +215,16 @@ func (svc *Service) handlePubSubCommand(ctx context.Context, token string, rawDa
|
||||
return ctxerr.Wrap(ctx, err, "update android command status from pub/sub")
|
||||
}
|
||||
|
||||
// WIPE ack is the authoritative signal that the device has been wiped (BYO: work profile removed; COBO: full factory reset). Flip
|
||||
// host_mdm.enrolled to 0 here rather than waiting on a separate STATUS_REPORT / ENROLLMENT with state=DELETED, which AMAPI does
|
||||
// not reliably send for a factory-reset COBO device (the agent is gone, nothing left to phone home). For BYO the DELETED
|
||||
// notification typically arrives and is now a no-op because we already flipped state.
|
||||
if cmd.CommandType == string(android.MDMAndroidCommandTypeWipe) && newStatus == string(android.MDMAndroidCommandStatusAcknowledged) {
|
||||
if err := svc.handleAndroidWipeAckUnenroll(ctx, cmd); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
svc.logger.InfoContext(ctx, "android pub/sub COMMAND processed",
|
||||
"operation_name", op.Name,
|
||||
"command_uuid", cmd.CommandUUID,
|
||||
@@ -195,6 +234,52 @@ func (svc *Service) handlePubSubCommand(ctx context.Context, token string, rawDa
|
||||
return nil
|
||||
}
|
||||
|
||||
// handleAndroidWipeAckUnenroll runs after a successful WIPE ack: flips host_mdm.enrolled, clears host_mdm_actions for BYO (so the
|
||||
// "Wiped" badge does not stick on a host whose only the work profile was removed), and emits mdm_unenrolled if state actually
|
||||
// changed. Returns errors so Pub/Sub retries on transient DB failures.
|
||||
func (svc *Service) handleAndroidWipeAckUnenroll(ctx context.Context, cmd *android.MDMAndroidCommand) error {
|
||||
ah, err := svc.ds.AndroidHostLiteByHostUUID(ctx, cmd.HostUUID)
|
||||
if err != nil {
|
||||
return ctxerr.Wrap(ctx, err, "android wipe-ack unenroll: lookup host by uuid")
|
||||
}
|
||||
if ah == nil || ah.Host == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
// BYO needs host_mdm_actions cleared so IsWiped() returns false post-ack -- only the work
|
||||
// profile was removed, not the device. COBO leaves wipe_ref intact so the "Wiped" badge sticks.
|
||||
if err := clearAndroidBYOWipeRef(ctx, svc.fleetDS, ah.Host.ID); err != nil {
|
||||
return ctxerr.Wrap(ctx, err, "android wipe-ack unenroll: clear byo wipe-ref")
|
||||
}
|
||||
|
||||
didUnenroll, err := svc.fleetDS.SetAndroidHostUnenrolled(ctx, ah.Host.ID)
|
||||
if err != nil {
|
||||
return ctxerr.Wrap(ctx, err, "android wipe-ack unenroll: set host_mdm unenrolled")
|
||||
}
|
||||
if !didUnenroll {
|
||||
// Already unenrolled (e.g. the API wrapper for BYO Unenroll already ran, a prior DELETED
|
||||
// notification beat us, or a prior delivery flipped state and is now retrying). No state
|
||||
// change, no activity. This also means activity emission is NOT retried on redelivery --
|
||||
// the tradeoff is no duplicate activity rows after a successful first delivery, at the cost
|
||||
// of losing the activity in the rare "flip succeeded then activity failed" race. The state
|
||||
// flip is what matters; the activity loss is detectable via logs.
|
||||
return nil
|
||||
}
|
||||
|
||||
displayName := ""
|
||||
if hosts, herr := svc.fleetDS.ListHostsLiteByIDs(ctx, []uint{ah.Host.ID}); herr == nil && len(hosts) == 1 && hosts[0] != nil {
|
||||
displayName = hosts[0].DisplayName()
|
||||
}
|
||||
if err := svc.newActivity(ctx, nil, fleet.ActivityTypeMDMUnenrolled{
|
||||
HostDisplayName: displayName,
|
||||
InstalledFromDEP: false,
|
||||
Platform: "android",
|
||||
}); err != nil {
|
||||
return ctxerr.Wrap(ctx, err, "android wipe-ack unenroll: emit mdm_unenrolled activity")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ackOrRetryUnknownAndroidOperation is the NotFound branch of handlePubSubCommand. It looks up
|
||||
// the host associated with the AMAPI device referenced by opName in Fleet's DB to distinguish
|
||||
// "race window, row will arrive" (host still exists -> retry) from "host was deleted, row is
|
||||
@@ -300,6 +385,12 @@ func (svc *Service) handlePubSubStatusReport(ctx context.Context, token string,
|
||||
return ctxerr.Wrap(ctx, err, "get host for deleted android device")
|
||||
}
|
||||
if host != nil {
|
||||
// Capture BYO-ness BEFORE flipping host_mdm.enrolled, then clear host_mdm_actions for BYO
|
||||
// so the post-ack "Wiped" badge clears (BYO unenroll only wipes the work profile).
|
||||
if err := clearAndroidBYOWipeRef(ctx, svc.fleetDS, host.Host.ID); err != nil {
|
||||
return ctxerr.Wrap(ctx, err, "clear byo wipe-ref on DELETED state")
|
||||
}
|
||||
|
||||
didUnenroll, err := svc.ds.SetAndroidHostUnenrolled(ctx, host.Host.ID)
|
||||
if err != nil {
|
||||
return ctxerr.Wrap(ctx, err, "set android host unenrolled on DELETED state")
|
||||
@@ -454,6 +545,12 @@ func (svc *Service) handlePubSubEnrollment(ctx context.Context, token string, ra
|
||||
return ctxerr.Wrap(ctx, herr, "get host for deleted android device (ENROLLMENT)")
|
||||
}
|
||||
if host != nil {
|
||||
// Capture BYO-ness BEFORE flipping host_mdm.enrolled, then clear host_mdm_actions for BYO
|
||||
// so the post-ack "Wiped" badge clears (BYO unenroll only wipes the work profile).
|
||||
if err := clearAndroidBYOWipeRef(ctx, svc.fleetDS, host.Host.ID); err != nil {
|
||||
return ctxerr.Wrap(ctx, err, "clear byo wipe-ref on DELETED state (ENROLLMENT)")
|
||||
}
|
||||
|
||||
if _, err := svc.ds.SetAndroidHostUnenrolled(ctx, host.Host.ID); err != nil {
|
||||
return ctxerr.Wrap(ctx, err, "set android host unenrolled on DELETED state (ENROLLMENT)")
|
||||
}
|
||||
@@ -604,6 +701,12 @@ func (svc *Service) updateHost(ctx context.Context, device *androidmanagement.De
|
||||
host.Device.LastPolicySyncTime = ptr.Time(policySyncTime)
|
||||
svc.verifyDevicePolicy(ctx, host.UUID, device)
|
||||
svc.verifyDeviceSoftware(ctx, host.Host, device)
|
||||
} else if fromEnroll {
|
||||
// Re-enrollment of a previously-enrolled host: the freshly-enrolled device has not applied any policy yet.
|
||||
// Clear stale data so that the host-specific policy is applied correctly.
|
||||
host.Device.AppliedPolicyID = nil
|
||||
host.Device.AppliedPolicyVersion = nil
|
||||
host.Device.LastPolicySyncTime = nil
|
||||
}
|
||||
|
||||
deviceID, err := svc.getDeviceID(ctx, device)
|
||||
@@ -661,6 +764,12 @@ func (svc *Service) updateHost(ctx context.Context, device *androidmanagement.De
|
||||
}
|
||||
|
||||
if fromEnroll {
|
||||
// Drop stale host_mdm_actions from a previous enrollment cycle so the re-enrolled device starts in "unlocked" device status with
|
||||
// no Lock/Wipe/Clear-passcode pending or Wiped badges.
|
||||
if err := svc.fleetDS.ClearHostMDMActions(ctx, host.Host.ID); err != nil {
|
||||
svc.logger.ErrorContext(ctx, "failed to clear host_mdm_actions on android re-enrollment", "host_id", host.Host.ID, "err", err)
|
||||
return ctxerr.Wrap(ctx, err, "clear host_mdm_actions on android re-enrollment")
|
||||
}
|
||||
// Delete any existing certificate template records for this host. The device has
|
||||
// lost all certificates on re-enrollment (work profile removed and re-installed, or
|
||||
// unenrolled/re-enrolled). This also clears stale rows from a previous team if the
|
||||
|
||||
@@ -375,6 +375,9 @@ func TestPubSubEnrollment(t *testing.T) {
|
||||
mockDS.DeleteAllHostCertificateTemplatesFunc = func(ctx context.Context, hostUUID string) error {
|
||||
return nil
|
||||
}
|
||||
mockDS.ClearHostMDMActionsFunc = func(ctx context.Context, hostID uint) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
var capturedHostUUID, capturedIdpUUID string
|
||||
mockDS.AssociateHostMDMIdPAccountFuncInvoked = false
|
||||
@@ -2333,6 +2336,127 @@ func TestPubSubCommand(t *testing.T) {
|
||||
require.False(t, mockDS.UpdateMDMAndroidCommandStatusFuncInvoked)
|
||||
})
|
||||
|
||||
// Already-terminal WIPE+ack redelivery matrix. The new hook in handlePubSubCommand re-runs handleAndroidWipeAckUnenroll for
|
||||
// terminal rows so transient DB failures on the first delivery recover on Pub/Sub retry. Two axes exercised:
|
||||
// - isBYO: BYO clears host_mdm_actions (wipe_ref dropped so the "Wiped" badge clears on the
|
||||
// work-profile-only wipe); COBO leaves it intact (badge persists post-factory-reset).
|
||||
// - priorDeliveryFlipped: when the prior delivery already flipped host_mdm.enrolled to 0,
|
||||
// SetAndroidHostUnenrolled's WHERE enrolled=1 matches no row -> didUnenroll=false ->
|
||||
// activity-prep lookup must short-circuit (no duplicate mdm_unenrolled in the feed).
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
isBYO bool
|
||||
priorDeliveryFlipped bool
|
||||
expectClearActions bool
|
||||
expectActivityPrep bool
|
||||
}{
|
||||
{
|
||||
name: "COBO first redelivery after transient failure flips state, leaves wipe_ref, emits activity",
|
||||
isBYO: false,
|
||||
priorDeliveryFlipped: false,
|
||||
expectClearActions: false,
|
||||
expectActivityPrep: true,
|
||||
},
|
||||
{
|
||||
name: "BYO first redelivery after transient failure flips state, clears wipe_ref, emits activity",
|
||||
isBYO: true,
|
||||
priorDeliveryFlipped: false,
|
||||
expectClearActions: true,
|
||||
expectActivityPrep: true,
|
||||
},
|
||||
{
|
||||
name: "redelivery after successful prior delivery is idempotent: no duplicate activity",
|
||||
isBYO: false,
|
||||
priorDeliveryFlipped: true,
|
||||
expectClearActions: false,
|
||||
expectActivityPrep: false,
|
||||
},
|
||||
} {
|
||||
t.Run("already-terminal WIPE+ack: "+tc.name, func(t *testing.T) {
|
||||
svc, mockDS := newSvc(t)
|
||||
|
||||
const hostID uint = 77
|
||||
stored := &android.MDMAndroidCommand{
|
||||
CommandUUID: "cmd-uuid-wipe-redelivered",
|
||||
HostUUID: "host-uuid-wipe",
|
||||
OperationName: "enterprises/E/devices/D/operations/wipe-redelivered",
|
||||
CommandType: string(android.MDMAndroidCommandTypeWipe),
|
||||
Status: string(android.MDMAndroidCommandStatusAcknowledged),
|
||||
}
|
||||
mockDS.GetMDMAndroidCommandByOperationNameFunc = func(ctx context.Context, opName string) (*android.MDMAndroidCommand, error) {
|
||||
return stored, nil
|
||||
}
|
||||
mockDS.UpdateMDMAndroidCommandStatusFunc = func(ctx context.Context, commandUUID, status string, errorCode, errorMessage *string) error {
|
||||
t.Fatalf("UpdateMDMAndroidCommandStatus must not be called for a terminal row")
|
||||
return nil
|
||||
}
|
||||
mockDS.AndroidHostLiteByHostUUIDFunc = func(ctx context.Context, hostUUID string) (*fleet.AndroidHost, error) {
|
||||
return &fleet.AndroidHost{Host: &fleet.Host{ID: hostID, UUID: hostUUID}}, nil
|
||||
}
|
||||
mockDS.GetHostMDMFunc = func(ctx context.Context, id uint) (*fleet.HostMDM, error) {
|
||||
return &fleet.HostMDM{IsPersonalEnrollment: tc.isBYO}, nil
|
||||
}
|
||||
mockDS.ClearHostMDMActionsFunc = func(ctx context.Context, id uint) error { return nil }
|
||||
mockDS.SetAndroidHostUnenrolledFunc = func(ctx context.Context, id uint) (bool, error) {
|
||||
// !priorDeliveryFlipped: first redelivery after a transient failure, WHERE enrolled=1
|
||||
// matches, returns true. priorDeliveryFlipped: prior delivery already flipped state,
|
||||
// no row matches, returns false.
|
||||
return !tc.priorDeliveryFlipped, nil
|
||||
}
|
||||
mockDS.ListHostsLiteByIDsFunc = func(ctx context.Context, ids []uint) ([]*fleet.Host, error) {
|
||||
if !tc.expectActivityPrep {
|
||||
t.Fatalf("ListHostsLiteByIDs must not be called when didUnenroll=false (activity is suppressed)")
|
||||
}
|
||||
return []*fleet.Host{{ID: hostID, Hostname: "redelivered-host"}}, nil
|
||||
}
|
||||
|
||||
msg := makeMessage(t, androidmanagement.Operation{Name: stored.OperationName, Done: true})
|
||||
require.NoError(t, svc.ProcessPubSubPush(t.Context(), validToken, msg))
|
||||
|
||||
require.False(t, mockDS.UpdateMDMAndroidCommandStatusFuncInvoked, "terminal row must not be re-transitioned")
|
||||
require.True(t, mockDS.SetAndroidHostUnenrolledFuncInvoked,
|
||||
"redelivery must always re-attempt the host_mdm flip (idempotent if already done)")
|
||||
require.Equal(t, tc.expectClearActions, mockDS.ClearHostMDMActionsFuncInvoked,
|
||||
"ClearHostMDMActions gating: BYO clears wipe_ref so the 'Wiped' badge drops, COBO preserves it")
|
||||
require.Equal(t, tc.expectActivityPrep, mockDS.ListHostsLiteByIDsFuncInvoked,
|
||||
"activity-prep lookup must run when state was newly flipped, and short-circuit when didUnenroll=false")
|
||||
})
|
||||
}
|
||||
|
||||
t.Run("WIPE+ack transient SetAndroidHostUnenrolled failure bubbles error so Pub/Sub retries", func(t *testing.T) {
|
||||
// A transient DB failure during the unenroll work must surface as an error from ProcessPubSubPush so Pub/Sub retries the
|
||||
// notification (which then re-enters via the already-terminal branch and re-runs the work).
|
||||
svc, mockDS := newSvc(t)
|
||||
|
||||
stored := &android.MDMAndroidCommand{
|
||||
CommandUUID: "cmd-uuid-wipe-transient",
|
||||
HostUUID: "host-uuid-wipe-transient",
|
||||
OperationName: "enterprises/E/devices/D/operations/wipe-transient",
|
||||
CommandType: string(android.MDMAndroidCommandTypeWipe),
|
||||
Status: string(android.MDMAndroidCommandStatusPending),
|
||||
}
|
||||
mockDS.GetMDMAndroidCommandByOperationNameFunc = func(ctx context.Context, opName string) (*android.MDMAndroidCommand, error) {
|
||||
return stored, nil
|
||||
}
|
||||
mockDS.UpdateMDMAndroidCommandStatusFunc = func(ctx context.Context, commandUUID, status string, errorCode, errorMessage *string) error {
|
||||
return nil
|
||||
}
|
||||
mockDS.AndroidHostLiteByHostUUIDFunc = func(ctx context.Context, hostUUID string) (*fleet.AndroidHost, error) {
|
||||
return &fleet.AndroidHost{Host: &fleet.Host{ID: 100, UUID: hostUUID}}, nil
|
||||
}
|
||||
mockDS.GetHostMDMFunc = func(ctx context.Context, id uint) (*fleet.HostMDM, error) {
|
||||
return &fleet.HostMDM{IsPersonalEnrollment: false}, nil
|
||||
}
|
||||
mockDS.SetAndroidHostUnenrolledFunc = func(ctx context.Context, id uint) (bool, error) {
|
||||
return false, errors.New("simulated transient DB connection drop")
|
||||
}
|
||||
|
||||
msg := makeMessage(t, androidmanagement.Operation{Name: stored.OperationName, Done: true})
|
||||
err := svc.ProcessPubSubPush(t.Context(), validToken, msg)
|
||||
require.Error(t, err, "transient DB failure must bubble so Pub/Sub retries")
|
||||
require.Contains(t, err.Error(), "simulated transient DB connection drop", "wrapped error must preserve the original cause")
|
||||
})
|
||||
|
||||
t.Run("unknown operation, host deleted from Fleet -> ack", func(t *testing.T) {
|
||||
// COBO unenroll / manual cleanup: the host is gone from Fleet, the command row is gone from mdm_android_commands, but Pub/Sub is
|
||||
// still trying to deliver the original notification. A false from AndroidDeviceExistsByDeviceID confirms the orphan; ack.
|
||||
@@ -2421,3 +2545,178 @@ func TestPubSubEnrollment_DoesNotPanicWhenHardwareInfoMissing(t *testing.T) {
|
||||
require.Contains(t, err.Error(), "missing hardware info")
|
||||
})
|
||||
}
|
||||
|
||||
// TestPubSubDeletedClearsWipeRefForBYO verifies that when AMAPI delivers a state=DELETED notification (via either STATUS_REPORT
|
||||
// or ENROLLMENT pub/sub topic) for a BYO Android host, Fleet clears host_mdm_actions (via ClearHostMDMActions) so
|
||||
// HostLockWipeStatus.IsWiped() returns false. COBO must NOT trigger this cleanup: COBO unenroll uses EnterprisesDevicesDelete (no
|
||||
// wipe_ref written), and COBO Wipe legitimately leaves the row so the "Wiped" badge persists.
|
||||
func TestPubSubDeletedClearsWipeRefForBYO(t *testing.T) {
|
||||
const existingHostID uint = 42
|
||||
// Deterministic ESID so a failing assertion always shows the same value.
|
||||
const enterpriseSpecificID = "ESI-BYO-CLEAR-FIXTURE"
|
||||
|
||||
buildDELETEDMessage := func(t *testing.T, topic android.NotificationType) *android.PubSubMessage {
|
||||
t.Helper()
|
||||
// validateDevice runs before the DELETED branch, so the payload must include hardwareInfo
|
||||
// + softwareInfo + memoryInfo even though those are unused for the unenroll path.
|
||||
device := androidmanagement.Device{
|
||||
Name: "enterprises/E1/devices/abc123",
|
||||
AppliedState: "DELETED",
|
||||
HardwareInfo: &androidmanagement.HardwareInfo{
|
||||
EnterpriseSpecificId: enterpriseSpecificID,
|
||||
Brand: "TestBrand",
|
||||
Model: "TestModel",
|
||||
},
|
||||
SoftwareInfo: &androidmanagement.SoftwareInfo{AndroidBuildNumber: "test-build", AndroidVersion: "1"},
|
||||
MemoryInfo: &androidmanagement.MemoryInfo{
|
||||
TotalRam: int64(8 * 1024 * 1024 * 1024),
|
||||
TotalInternalStorage: int64(64 * 1024 * 1024 * 1024),
|
||||
},
|
||||
}
|
||||
data, err := json.Marshal(device)
|
||||
require.NoError(t, err)
|
||||
return &android.PubSubMessage{
|
||||
Attributes: map[string]string{"notificationType": string(topic)},
|
||||
Data: base64.StdEncoding.EncodeToString(data),
|
||||
}
|
||||
}
|
||||
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
topic android.NotificationType
|
||||
isBYO bool
|
||||
expectClearCalled bool
|
||||
}{
|
||||
{name: "STATUS_REPORT BYO clears wipe_ref", topic: android.PubSubStatusReport, isBYO: true, expectClearCalled: true},
|
||||
{name: "STATUS_REPORT COBO leaves wipe_ref intact", topic: android.PubSubStatusReport, isBYO: false, expectClearCalled: false},
|
||||
{name: "ENROLLMENT BYO clears wipe_ref", topic: android.PubSubEnrollment, isBYO: true, expectClearCalled: true},
|
||||
{name: "ENROLLMENT COBO leaves wipe_ref intact", topic: android.PubSubEnrollment, isBYO: false, expectClearCalled: false},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
svc, mockDS := createAndroidService(t)
|
||||
|
||||
// Capture invocation args outside the closures so assertions run AFTER ProcessPubSubPush --
|
||||
// if a mock is never called, the test still sees the missing call instead of silently passing.
|
||||
var getHostMDMArgs []uint
|
||||
var clearActionsArgs []uint
|
||||
|
||||
mockDS.AppConfigFunc = func(ctx context.Context) (*fleet.AppConfig, error) {
|
||||
return &fleet.AppConfig{MDM: fleet.MDM{AndroidEnabledAndConfigured: true}}, nil
|
||||
}
|
||||
mockDS.AndroidHostLiteFunc = func(ctx context.Context, esID string) (*fleet.AndroidHost, error) {
|
||||
return &fleet.AndroidHost{
|
||||
Host: &fleet.Host{ID: existingHostID, UUID: enterpriseSpecificID},
|
||||
Device: &android.Device{
|
||||
HostID: existingHostID,
|
||||
DeviceID: "abc123",
|
||||
EnterpriseSpecificID: new(enterpriseSpecificID),
|
||||
},
|
||||
}, nil
|
||||
}
|
||||
mockDS.GetHostMDMFunc = func(ctx context.Context, hostID uint) (*fleet.HostMDM, error) {
|
||||
getHostMDMArgs = append(getHostMDMArgs, hostID)
|
||||
return &fleet.HostMDM{IsPersonalEnrollment: tc.isBYO}, nil
|
||||
}
|
||||
mockDS.SetAndroidHostUnenrolledFunc = func(ctx context.Context, hostID uint) (bool, error) {
|
||||
return true, nil
|
||||
}
|
||||
mockDS.MarkAllPendingVPPInstallsAsFailedForAndroidHostFunc = func(ctx context.Context, hostID uint) ([]*fleet.User, []fleet.ActivityDetails, error) {
|
||||
return nil, nil, nil
|
||||
}
|
||||
mockDS.ClearHostMDMActionsFunc = func(ctx context.Context, hostID uint) error {
|
||||
clearActionsArgs = append(clearActionsArgs, hostID)
|
||||
return nil
|
||||
}
|
||||
// DELETED handler emits mdm_unenrolled activity and looks up display_name + hardware_serial first.
|
||||
mockDS.ListHostsLiteByIDsFunc = func(ctx context.Context, ids []uint) ([]*fleet.Host, error) {
|
||||
return []*fleet.Host{{ID: existingHostID, Hostname: "deleted-host"}}, nil
|
||||
}
|
||||
|
||||
require.NoError(t, svc.ProcessPubSubPush(context.Background(), "value", buildDELETEDMessage(t, tc.topic)))
|
||||
|
||||
require.True(t, mockDS.SetAndroidHostUnenrolledFuncInvoked, "host_mdm flip must always run on DELETED, regardless of BYO/COBO")
|
||||
require.Equal(t, []uint{existingHostID}, getHostMDMArgs, "GetHostMDM must be called once with the host_id")
|
||||
require.Equal(t, tc.expectClearCalled, mockDS.ClearHostMDMActionsFuncInvoked,
|
||||
"ClearHostMDMActions invocation must gate on BYO so post-unenroll badge clears for BYO and persists 'Wiped' for COBO")
|
||||
if tc.expectClearCalled {
|
||||
require.Equal(t, []uint{existingHostID}, clearActionsArgs, "ClearHostMDMActions arg must match host_id")
|
||||
} else {
|
||||
require.Empty(t, clearActionsArgs)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestPubSubEnrollment_ClearsHostMDMActionsOnReEnroll verifies the re-enrollment cleanup: when an enrollment message arrives for
|
||||
// a host that already exists in Fleet (typical re-enrollment cycle: factory reset -> re-enroll on the same physical device),
|
||||
// updateHost is invoked with fromEnroll=true and must clear host_mdm_actions so stale Lock/Wipe/Clear-passcode refs from the
|
||||
// previous enrollment do not bleed into the new one.
|
||||
func TestPubSubEnrollment_ClearsHostMDMActionsOnReEnroll(t *testing.T) {
|
||||
svc, mockDS := createAndroidService(t)
|
||||
|
||||
// COBO enrollment with no EnterpriseSpecificId in HardwareInfo. getAndroidHostKey falls back to sha256(brand:serial)
|
||||
const existingHostID uint = 42
|
||||
|
||||
mockDS.AppConfigFunc = func(ctx context.Context) (*fleet.AppConfig, error) {
|
||||
return &fleet.AppConfig{MDM: fleet.MDM{AndroidEnabledAndConfigured: true}}, nil
|
||||
}
|
||||
mockDS.VerifyEnrollSecretFunc = func(ctx context.Context, secret string) (*fleet.EnrollSecret, error) {
|
||||
return &fleet.EnrollSecret{Secret: "global"}, nil
|
||||
}
|
||||
// Existing host: AndroidHostLite returns a real host so enrollHost falls into the updateHost (fromEnroll=true) branch instead of
|
||||
// NewAndroidHost. AppliedPolicyID is seeded with the prior cycle's host-specific policy id so the regression check below
|
||||
// (re-enroll must reset it) has something stale to clear.
|
||||
stalePolicyID := testBrandTestSerialHashed
|
||||
stalePolicyVersion := int64(5)
|
||||
staleSyncTime := time.Date(2026, 5, 1, 0, 0, 0, 0, time.UTC)
|
||||
mockDS.AndroidHostLiteFunc = func(ctx context.Context, esID string) (*fleet.AndroidHost, error) {
|
||||
require.Equal(t, testBrandTestSerialHashed, esID,
|
||||
"AndroidHostLite must be called with the brand:serial hash for COBO re-enrollment")
|
||||
return &fleet.AndroidHost{
|
||||
Host: &fleet.Host{
|
||||
ID: existingHostID,
|
||||
UUID: testBrandTestSerialHashed,
|
||||
},
|
||||
Device: &android.Device{
|
||||
HostID: existingHostID,
|
||||
DeviceID: "device-reenroll",
|
||||
EnterpriseSpecificID: new(testBrandTestSerialHashed),
|
||||
AppliedPolicyID: &stalePolicyID,
|
||||
AppliedPolicyVersion: &stalePolicyVersion,
|
||||
LastPolicySyncTime: &staleSyncTime,
|
||||
},
|
||||
}, nil
|
||||
}
|
||||
mockDS.UpdateAndroidHostFunc = func(ctx context.Context, host *fleet.AndroidHost, fromEnroll, companyOwned bool) error {
|
||||
require.True(t, fromEnroll, "re-enrollment must invoke updateHost with fromEnroll=true")
|
||||
require.Nil(t, host.Device.AppliedPolicyID, "re-enroll must reset stale applied_policy_id")
|
||||
require.Nil(t, host.Device.AppliedPolicyVersion, "re-enroll must reset stale applied_policy_version")
|
||||
require.Nil(t, host.Device.LastPolicySyncTime, "re-enroll must reset stale last_policy_sync_time")
|
||||
return nil
|
||||
}
|
||||
mockDS.ClearHostMDMActionsFunc = func(ctx context.Context, hostID uint) error {
|
||||
require.Equal(t, existingHostID, hostID)
|
||||
return nil
|
||||
}
|
||||
mockDS.DeleteAllHostCertificateTemplatesFunc = func(ctx context.Context, hostUUID string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
enrollmentToken := enrollmentTokenRequest{EnrollSecret: "global"}
|
||||
enrollTokenData, err := json.Marshal(enrollmentToken)
|
||||
require.NoError(t, err)
|
||||
// createEnrollmentMessage unconditionally overwrites HardwareInfo (brand/model/hardware) and, for COBO ownership, adds
|
||||
// serial="test-serial" without EnterpriseSpecificId. So the lookup key produced by getAndroidHostKey is
|
||||
// sha256("TestBrand:test-serial") = testBrandTestSerialHashed.
|
||||
msg := createEnrollmentMessage(t, androidmanagement.Device{
|
||||
Name: createAndroidDeviceId("reenroll"),
|
||||
EnrollmentTokenData: string(enrollTokenData),
|
||||
Ownership: DeviceOwnershipCompanyOwned,
|
||||
})
|
||||
|
||||
require.NoError(t, svc.ProcessPubSubPush(t.Context(), "value", msg))
|
||||
require.True(t, mockDS.AndroidHostLiteFuncInvoked,
|
||||
"AndroidHostLite must be invoked to detect existing host on re-enroll")
|
||||
require.True(t, mockDS.ClearHostMDMActionsFuncInvoked,
|
||||
"ClearHostMDMActions must be called on Android re-enrollment to drop stale lock/wipe/clear-passcode refs")
|
||||
}
|
||||
|
||||
@@ -77,6 +77,14 @@ func ReconcileAndroidDevices(ctx context.Context, ds fleet.Datastore, logger *sl
|
||||
// Device exists, no-op.
|
||||
continue
|
||||
case !ok:
|
||||
// BYO unenroll wipes only the work profile; clear host_mdm_actions before flipping host_mdm.enrolled so the post-ack "Wiped"
|
||||
// badge clears.
|
||||
if cerr := clearAndroidBYOWipeRef(ctx, ds, dev.HostID); cerr != nil {
|
||||
logger.ErrorContext(ctx, "failed to clear android byo wipe-ref during reconcile", "host_id", dev.HostID, "err", cerr)
|
||||
ctxerr.Handle(ctx, cerr)
|
||||
continue
|
||||
}
|
||||
|
||||
if _, derr := ds.SetAndroidHostUnenrolled(ctx, dev.HostID); derr != nil {
|
||||
logger.ErrorContext(ctx, "failed to mark android host unenrolled during reconcile", "host_id", dev.HostID, "err", derr)
|
||||
continue
|
||||
|
||||
@@ -878,14 +878,11 @@ func (svc *Service) UnenrollAndroidHost(ctx context.Context, hostID uint) error
|
||||
_ = svc.androidAPIClient.SetAuthenticationSecret(secret)
|
||||
deviceName := fmt.Sprintf("enterprises/%s/devices/%s", enterprise.EnterpriseID, ah.Device.DeviceID)
|
||||
|
||||
// BYO unenroll runs an AMAPI WIPE command (which on a BYO/personal device only wipes the work
|
||||
// profile, leaving the personal side intact) instead of the EnterprisesDevicesDelete call.
|
||||
// The mdm_unenrolled activity is emitted later, when the device removes its work profile and
|
||||
// AMAPI sends the resulting STATUS_REPORT (or ENROLLMENT) notification with state=DELETED --
|
||||
// see handlePubSubStatusReport / handlePubSubEnrollment. The COMMAND notification path only
|
||||
// transitions the mdm_android_commands row from pending to acknowledged/error.
|
||||
// For COBO we keep the existing delete-device behavior (terminates management without
|
||||
// factory-resetting the device).
|
||||
// BYO unenroll runs an AMAPI WIPE command (which on a BYO/personal device only wipes the work profile, leaving the personal side
|
||||
// intact) instead of the EnterprisesDevicesDelete call. host_mdm_actions.wipe_ref is written so device_status reflects "wiping"
|
||||
// while the work-profile wipe is in flight; the HostHeader badge overrides the label to "Unenroll pending" since the admin
|
||||
// clicked Unenroll, not Wipe. The mdm_unenrolled activity is emitted later, when the device removes its work profile and AMAPI
|
||||
// sends the resulting STATUS_REPORT (or ENROLLMENT) notification with state=DELETED
|
||||
hostMDM, err := svc.fleetDS.GetHostMDM(ctx, host.ID)
|
||||
if err != nil && !fleet.IsNotFound(err) {
|
||||
return ctxerr.Wrap(ctx, err, "getting host_mdm for android unenrollment")
|
||||
@@ -902,7 +899,7 @@ func (svc *Service) UnenrollAndroidHost(ctx context.Context, hostID uint) error
|
||||
return ctxerr.Wrap(ctx, err, "amapi issue byo-unenroll wipe command")
|
||||
}
|
||||
|
||||
// Persist the row but don't write wipe_ref: BYO unenroll surfaces as the mdm_unenrolled activity, not as a wipe in the UI.
|
||||
// Write wipe_ref so device_status flips to "wiping" while the work-profile wipe is in flight.
|
||||
cmd := &android.MDMAndroidCommand{
|
||||
CommandUUID: uuid.NewString(),
|
||||
HostUUID: host.UUID,
|
||||
@@ -910,7 +907,7 @@ func (svc *Service) UnenrollAndroidHost(ctx context.Context, hostID uint) error
|
||||
CommandType: string(android.MDMAndroidCommandTypeWipe),
|
||||
Status: string(android.MDMAndroidCommandStatusPending),
|
||||
}
|
||||
if err := svc.fleetDS.NewMDMAndroidCommand(ctx, cmd); err != nil {
|
||||
if err := svc.fleetDS.WipeHostViaAndroidMDM(ctx, host, cmd); err != nil {
|
||||
svc.logger.ErrorContext(ctx, "amapi byo-unenroll wipe issued but local persist failed",
|
||||
"host_id", host.ID, "operation_name", op.Name, "err", err)
|
||||
return ctxerr.Wrap(ctx, err, "persist android byo-unenroll wipe command")
|
||||
@@ -1014,8 +1011,7 @@ func (svc *Service) LockAndroidHost(ctx context.Context, hostID uint) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// ClearAndroidPasscode issues an AMAPI RESET_PASSWORD with newPassword="" and persists the row. Unlike Lock/Wipe,
|
||||
// ClearPasscode is a one-shot action with no UI lock state, so it does NOT touch host_mdm_actions.
|
||||
// ClearAndroidPasscode issues an AMAPI RESET_PASSWORD with newPassword="" and persists the row plus host_mdm_actions.clear_passcode_ref.
|
||||
func (svc *Service) ClearAndroidPasscode(ctx context.Context, hostID uint) (string, error) {
|
||||
host, deviceName, err := svc.resolveAndroidCommandTarget(ctx, hostID, "clear-passcode")
|
||||
if err != nil {
|
||||
@@ -1038,7 +1034,7 @@ func (svc *Service) ClearAndroidPasscode(ctx context.Context, hostID uint) (stri
|
||||
CommandType: string(android.MDMAndroidCommandTypeResetPassword),
|
||||
Status: string(android.MDMAndroidCommandStatusPending),
|
||||
}
|
||||
if err := svc.fleetDS.NewMDMAndroidCommand(ctx, cmd); err != nil {
|
||||
if err := svc.fleetDS.ClearPasscodeHostViaAndroidMDM(ctx, host, cmd); err != nil {
|
||||
svc.logger.ErrorContext(ctx, "amapi clear-passcode issued but local state write failed",
|
||||
"host_id", host.ID, "operation_name", op.Name, "err", err)
|
||||
return "", ctxerr.Wrap(ctx, err, "persist android clear-passcode command")
|
||||
|
||||
@@ -1775,6 +1775,10 @@ type LockHostViaAndroidMDMFunc func(ctx context.Context, host *fleet.Host, cmd *
|
||||
|
||||
type WipeHostViaAndroidMDMFunc func(ctx context.Context, host *fleet.Host, cmd *android.MDMAndroidCommand) error
|
||||
|
||||
type ClearPasscodeHostViaAndroidMDMFunc func(ctx context.Context, host *fleet.Host, cmd *android.MDMAndroidCommand) error
|
||||
|
||||
type ClearHostMDMActionsFunc func(ctx context.Context, hostID uint) error
|
||||
|
||||
type GetLatestAppleMDMCommandOfTypeFunc func(ctx context.Context, hostUUID string, commandType string) (*fleet.MDMCommand, error)
|
||||
|
||||
type SetLockCommandForLostModeCheckinFunc func(ctx context.Context, hostID uint, commandUUID string) error
|
||||
@@ -4644,6 +4648,12 @@ type DataStore struct {
|
||||
WipeHostViaAndroidMDMFunc WipeHostViaAndroidMDMFunc
|
||||
WipeHostViaAndroidMDMFuncInvoked bool
|
||||
|
||||
ClearPasscodeHostViaAndroidMDMFunc ClearPasscodeHostViaAndroidMDMFunc
|
||||
ClearPasscodeHostViaAndroidMDMFuncInvoked bool
|
||||
|
||||
ClearHostMDMActionsFunc ClearHostMDMActionsFunc
|
||||
ClearHostMDMActionsFuncInvoked bool
|
||||
|
||||
GetLatestAppleMDMCommandOfTypeFunc GetLatestAppleMDMCommandOfTypeFunc
|
||||
GetLatestAppleMDMCommandOfTypeFuncInvoked bool
|
||||
|
||||
@@ -11139,6 +11149,20 @@ func (s *DataStore) WipeHostViaAndroidMDM(ctx context.Context, host *fleet.Host,
|
||||
return s.WipeHostViaAndroidMDMFunc(ctx, host, cmd)
|
||||
}
|
||||
|
||||
func (s *DataStore) ClearPasscodeHostViaAndroidMDM(ctx context.Context, host *fleet.Host, cmd *android.MDMAndroidCommand) error {
|
||||
s.mu.Lock()
|
||||
s.ClearPasscodeHostViaAndroidMDMFuncInvoked = true
|
||||
s.mu.Unlock()
|
||||
return s.ClearPasscodeHostViaAndroidMDMFunc(ctx, host, cmd)
|
||||
}
|
||||
|
||||
func (s *DataStore) ClearHostMDMActions(ctx context.Context, hostID uint) error {
|
||||
s.mu.Lock()
|
||||
s.ClearHostMDMActionsFuncInvoked = true
|
||||
s.mu.Unlock()
|
||||
return s.ClearHostMDMActionsFunc(ctx, hostID)
|
||||
}
|
||||
|
||||
func (s *DataStore) GetLatestAppleMDMCommandOfType(ctx context.Context, hostUUID string, commandType string) (*fleet.MDMCommand, error) {
|
||||
s.mu.Lock()
|
||||
s.GetLatestAppleMDMCommandOfTypeFuncInvoked = true
|
||||
|
||||
@@ -18670,6 +18670,12 @@ func (s *integrationMDMTestSuite) TestAndroidHostUnenrollMDM() {
|
||||
require.Equal(t, "315360000s", issuedCommand.Duration, "android commands must use the long duration to mirror Apple/Windows queue semantics")
|
||||
require.NotEmpty(t, issuedToDeviceName)
|
||||
|
||||
// wipe_ref must be written so device_status flips to "wiping"
|
||||
var byoResp getHostResponse
|
||||
s.DoJSON("GET", fmt.Sprintf("/api/latest/fleet/hosts/%d", byoHostID), nil, http.StatusOK, &byoResp)
|
||||
require.NotNil(t, byoResp.Host.MDM.PendingAction)
|
||||
require.Equal(t, "wipe", *byoResp.Host.MDM.PendingAction, "BYO unenroll must write wipe_ref so device_status reflects 'wiping' until the AMAPI ack")
|
||||
|
||||
// Reset between sub-cases.
|
||||
didCallAMAPIDelete = false
|
||||
didCallAMAPIIssueWipe = false
|
||||
@@ -18680,6 +18686,11 @@ func (s *integrationMDMTestSuite) TestAndroidHostUnenrollMDM() {
|
||||
s.Do("DELETE", fmt.Sprintf("/api/latest/fleet/hosts/%d/mdm", coboHostID), nil, http.StatusNoContent)
|
||||
require.True(t, didCallAMAPIDelete, "COBO unenroll must call EnterprisesDevicesDelete")
|
||||
require.False(t, didCallAMAPIIssueWipe, "COBO unenroll must not call EnterprisesDevicesIssueCommand")
|
||||
|
||||
// host_mdm.enrolled must stay 1 here. EnterprisesDevicesDelete returns 200 when AMAPI accepts the request, not when the device has processed it.
|
||||
coboHostMDM, err := s.ds.GetHostMDM(ctx, coboHostID)
|
||||
require.NoError(t, err)
|
||||
require.True(t, coboHostMDM.Enrolled, "host_mdm.enrolled must stay true until the device acks via Pub/Sub DELETED")
|
||||
}
|
||||
|
||||
// TestAndroidLockWipeClearPasscode exercises the three commands end-to-end against the real Fleet HTTP handler stack with a
|
||||
@@ -18834,11 +18845,28 @@ func (s *integrationMDMTestSuite) TestAndroidLockWipeClearPasscode() {
|
||||
require.Equal(t, string(android.MDMAndroidCommandTypeWipe), row.CommandType)
|
||||
|
||||
assertHostMDMStatus(t, wipeHostID, "wipe", "unlocked")
|
||||
|
||||
// COBO Wipe ack must flip host_mdm.enrolled directly. AMAPI does not reliably send a STATUS_REPORT / ENROLLMENT with
|
||||
// state=DELETED for a factory-reset COBO device (the agent is gone), so the COMMAND ack is the only authoritative unenroll
|
||||
// signal. Without this, the host page sticks at "MDM On" + "Wiped" badge forever.
|
||||
deliverPubSubCommand(t, androidmanagement.Operation{Name: opName, Done: true})
|
||||
|
||||
row, err = s.ds.GetMDMAndroidCommandByOperationName(ctx, opName)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, string(android.MDMAndroidCommandStatusAcknowledged), row.Status)
|
||||
|
||||
wipeHostMDM, err := s.ds.GetHostMDM(ctx, wipeHostID)
|
||||
require.NoError(t, err)
|
||||
require.False(t, wipeHostMDM.Enrolled, "COBO Wipe ack must flip host_mdm.enrolled to false")
|
||||
|
||||
// wipe_ref stays so the host page surfaces "Wiped" -- COBO Wipe is destructive and the
|
||||
// badge is correct. The host stays in Fleet until an admin deletes it manually or device re-enrolls.
|
||||
assertHostMDMStatus(t, wipeHostID, "", "wiped")
|
||||
})
|
||||
|
||||
t.Run("Clear passcode uses RESET_PASSWORD with empty newPassword and does not touch host_mdm_actions", func(t *testing.T) {
|
||||
t.Run("Clear passcode uses RESET_PASSWORD with empty newPassword and transitions clear_passcode_ref through Pub/Sub ack", func(t *testing.T) {
|
||||
// Fresh host to keep assertions self-contained; BYO vs COBO doesn't matter for
|
||||
// clear-passcode (no host_mdm_actions write either way).
|
||||
// clear-passcode (both surface the same "Clear passcode pending" device status).
|
||||
passHostID := createAndroidHostForTest(t, s.ds, nil, false)
|
||||
|
||||
var cpResp fleet.ClearPasscodeResponse
|
||||
@@ -18859,8 +18887,17 @@ func (s *integrationMDMTestSuite) TestAndroidLockWipeClearPasscode() {
|
||||
require.Equal(t, row.CommandUUID, cpResp.CommandUUID,
|
||||
"the CommandUUID returned to API consumers must match the persisted row (#41683 review fix)")
|
||||
|
||||
// No PendingAction surface for clear-passcode -- it doesn't gate other actions, so the
|
||||
// host page reports an unlocked, no-pending state.
|
||||
// host_mdm_actions.clear_passcode_ref is written so device_status flips to "clearing passcode" while the AMAPI command is in flight
|
||||
assertHostMDMStatus(t, passHostID, "clear_passcode", "unlocked")
|
||||
|
||||
// Pub/Sub COMMAND ack clears the ref and returns the host to its baseline (unlocked, no pending action). Mirrors the symmetric
|
||||
// round-trip the Lock subtest above performs.
|
||||
deliverPubSubCommand(t, androidmanagement.Operation{Name: opName, Done: true})
|
||||
|
||||
row, err = s.ds.GetMDMAndroidCommandByOperationName(ctx, opName)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, string(android.MDMAndroidCommandStatusAcknowledged), row.Status)
|
||||
|
||||
assertHostMDMStatus(t, passHostID, "", "unlocked")
|
||||
})
|
||||
|
||||
|
||||
Reference in New Issue
Block a user