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 -->

[![Review Change
Stack](https://storage.googleapis.com/coderabbit_public_assets/review-stack-in-coderabbit-ui.svg)](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:
Victor Lyuboslavsky
2026-05-28 16:32:24 -05:00
committed by GitHub
parent 096b7b16fb
commit 65708f9398
28 changed files with 1303 additions and 111 deletions
+7 -1
View File
@@ -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;
+10
View File
@@ -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
@@ -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");
}
}}
/>
)}
</>
);
@@ -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>
@@ -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&apos;t fetch data from <br /> a wiped host.
</>
),
clearing_passcode: (
<>
You can&apos;t fetch data from <br /> a host that is clearing passcode.
</>
),
} as const;
+8 -1
View File
@@ -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
+20 -5
View File
@@ -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 = `
+68 -5
View File
@@ -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) {
+39
View File
@@ -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
@@ -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
+47 -30
View File
@@ -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
}
}
}
+14 -5
View File
@@ -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
View File
@@ -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*).
+110 -1
View File
@@ -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
+299
View File
@@ -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
+9 -13
View File
@@ -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")
+24
View File
@@ -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
+41 -4
View File
@@ -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")
})