@@ -579,6 +579,348 @@ func (s *integrationMDMTestSuite) TestSetupExperienceFlowWithSoftwareAndScriptAu
s . lastActivityMatches ( fleet . ActivityTypeRanScript { } . ActivityName ( ) , expectedActivityDetail , 0 )
}
// TestSetupExperienceFlowWithFMAAndVersionRollback tests the full setup
// experience flow using a Fleet Maintained App (FMA) as the software to
// install, and exercises the FMA version rollback functionality: the FMA is
// added at v1.0, upgraded to v2.0 (so both are cached), then rolled back to
// v1.0 via the batch-set endpoint. The setup experience is then driven to
// completion using the rolled-back installer and the device is auto-released.
func ( s * integrationMDMTestSuite ) TestSetupExperienceFlowWithFMAAndVersionRollback ( ) {
t := s . T ( )
ctx := context . Background ( )
s . setSkipWorkerJobs ( t )
// -------------------------------------------------------------------------
// Set up manifest + installer mock servers for a darwin FMA using the
// shared startFMAServers helper.
// We use dummy_installer.pkg as the on-disk bytes so the server can parse
// it as a real macOS pkg. The SHA is computed by fmaTestState.ComputeSHA.
// -------------------------------------------------------------------------
pkgBytes , err := os . ReadFile ( "testdata/software-installers/dummy_installer.pkg" )
require . NoError ( t , err )
// v2 uses slightly different bytes so it gets a distinct SHA and storage ID.
v2Bytes := fmt . Append ( pkgBytes , [ ] byte ( "v2" ) )
// fmaState is the single mutable state object the manifest server reads.
// startFMAServers calls ComputeSHA on the initial installerBytes.
fmaState := & fmaTestState {
version : "1.0" ,
installerBytes : pkgBytes ,
installerPath : "/1password.pkg" ,
}
startFMAServers ( t , s . ds , map [ string ] * fmaTestState {
"/1password/darwin.json" : fmaState ,
} )
// -------------------------------------------------------------------------
// Helper: issue a batch-set request and wait for completion.
// -------------------------------------------------------------------------
batchSet := func ( tm fleet . Team , software [ ] * fleet . SoftwareInstallerPayload ) [ ] fleet . SoftwarePackageResponse {
var resp batchSetSoftwareInstallersResponse
s . DoJSON ( "POST" , "/api/latest/fleet/software/batch" ,
batchSetSoftwareInstallersRequest { Software : software , TeamName : tm . Name } ,
http . StatusAccepted , & resp ,
"team_name" , tm . Name , "team_id" , fmt . Sprint ( tm . ID ) ,
)
return waitBatchSetSoftwareInstallersCompleted ( t , & s . withServer , tm . Name , resp . RequestUUID )
}
// -------------------------------------------------------------------------
// Create the team and DEP-enroll a device into it (same as the Auto-Release
// test, but we inject the FMA instead of a custom .pkg).
// -------------------------------------------------------------------------
s . enableABM ( "fleet-setup-experience-fma" )
tm , err := s . ds . NewTeam ( ctx , & fleet . Team { Name : t . Name ( ) + "-team" } )
require . NoError ( t , err )
teamDevice := godep . Device {
SerialNumber : uuid . New ( ) . String ( ) ,
Model : "MacBook Pro" ,
OS : "osx" ,
OpType : "added" ,
}
// Add a team MDM profile so we can assert on the profile install commands.
teamProfile := mobileconfigForTest ( "N1" , "I1" )
s . Do ( "POST" , "/api/v1/fleet/mdm/apple/profiles/batch" ,
batchSetMDMAppleProfilesRequest { Profiles : [ ] [ ] byte { teamProfile } } ,
http . StatusNoContent , "team_id" , fmt . Sprint ( tm . ID ) )
// -------------------------------------------------------------------------
// Section 1: add the FMA at v1.0 via batch-set, then upgrade to v2.0 so
// that two versions are cached, then roll back to v1.0.
// -------------------------------------------------------------------------
// Add v1.0.
packages := batchSet ( * tm , [ ] * fleet . SoftwareInstallerPayload {
{ Slug : ptr . String ( "1password/darwin" ) } ,
} )
require . Len ( t , packages , 1 )
require . NotNil ( t , packages [ 0 ] . TitleID )
fmaTitleID := * packages [ 0 ] . TitleID
// Verify the active version is v1.0.
var titlesResp listSoftwareTitlesResponse
s . DoJSON ( "GET" , "/api/latest/fleet/software/titles" ,
listSoftwareTitlesRequest { } , http . StatusOK , & titlesResp ,
"available_for_install" , "true" ,
"team_id" , fmt . Sprint ( tm . ID ) ,
)
require . Len ( t , titlesResp . SoftwareTitles , 1 )
require . Equal ( t , "1.0" , titlesResp . SoftwareTitles [ 0 ] . SoftwarePackage . Version )
require . Len ( t , titlesResp . SoftwareTitles [ 0 ] . SoftwarePackage . FleetMaintainedVersions , 1 )
// Advance to v2.0 — both v1 and v2 are now cached.
fmaState . version = "2.0"
fmaState . installerBytes = v2Bytes
fmaState . ComputeSHA ( v2Bytes )
packages = batchSet ( * tm , [ ] * fleet . SoftwareInstallerPayload {
{ Slug : ptr . String ( "1password/darwin" ) } ,
} )
require . Len ( t , packages , 2 , "both v1.0 and v2.0 should be cached" )
titlesResp = listSoftwareTitlesResponse { }
s . DoJSON ( "GET" , "/api/latest/fleet/software/titles" ,
listSoftwareTitlesRequest { } , http . StatusOK , & titlesResp ,
"available_for_install" , "true" ,
"team_id" , fmt . Sprint ( tm . ID ) ,
)
require . Len ( t , titlesResp . SoftwareTitles , 1 )
require . Equal ( t , "2.0" , titlesResp . SoftwareTitles [ 0 ] . SoftwarePackage . Version )
require . Len ( t , titlesResp . SoftwareTitles [ 0 ] . SoftwarePackage . FleetMaintainedVersions , 2 )
// Roll back to v1.0 by specifying RollbackVersion in the batch request
// (simulating a GitOps yaml that pins fleet_maintained_app_version: "1.0").
// The manifest server still advertises v2.0, but the rollback tells the
// batch-set endpoint to activate the already-cached v1.0 installer instead
// of downloading again.
packages = batchSet ( * tm , [ ] * fleet . SoftwareInstallerPayload {
{ Slug : ptr . String ( "1password/darwin" ) , RollbackVersion : "1.0" } ,
} )
require . Len ( t , packages , 2 , "both versions should still be cached after rollback" )
titlesResp = listSoftwareTitlesResponse { }
s . DoJSON ( "GET" , "/api/latest/fleet/software/titles" ,
listSoftwareTitlesRequest { } , http . StatusOK , & titlesResp ,
"available_for_install" , "true" ,
"team_id" , fmt . Sprint ( tm . ID ) ,
)
require . Len ( t , titlesResp . SoftwareTitles , 1 )
require . Equal ( t , "1.0" , titlesResp . SoftwareTitles [ 0 ] . SoftwarePackage . Version ,
"active version must be v1.0 after rollback" )
// -------------------------------------------------------------------------
// Section 2: configure setup experience to install the (rolled-back) FMA,
// then drive a full DEP enrollment through to auto-release.
// -------------------------------------------------------------------------
// Mark the FMA title as a setup experience install.
var swInstallResp putSetupExperienceSoftwareResponse
s . DoJSON ( "PUT" , "/api/v1/fleet/setup_experience/software" ,
putSetupExperienceSoftwareRequest { TeamID : tm . ID , TitleIDs : [ ] uint { fmaTitleID } } ,
http . StatusOK , & swInstallResp )
s . lastActivityOfTypeMatches ( fleet . ActivityEditedSetupExperienceSoftware { } . ActivityName ( ) ,
fmt . Sprintf ( ` { "platform": "darwin", "fleet_id": %d, "fleet_name": "%s", "team_id": %d, "team_name": "%s"} ` ,
tm . ID , tm . Name , tm . ID , tm . Name ) , 0 )
s . pushProvider . PushFunc = func ( _ context . Context , pushes [ ] * mdm . Push ) ( map [ string ] * push . Response , error ) {
return map [ string ] * push . Response { } , nil
}
s . mockDEPResponse ( "fleet-setup-experience-fma" , http . HandlerFunc ( func ( w http . ResponseWriter , r * http . Request ) {
encoder := json . NewEncoder ( w )
switch r . URL . Path {
case "/session" :
require . NoError ( t , encoder . Encode ( map [ string ] string { "auth_session_token" : "xyz" } ) )
case "/profile" :
require . NoError ( t , encoder . Encode ( godep . ProfileResponse { ProfileUUID : uuid . New ( ) . String ( ) } ) )
case "/server/devices" :
require . NoError ( t , encoder . Encode ( godep . DeviceResponse { Devices : [ ] godep . Device { teamDevice } } ) )
case "/devices/sync" :
require . NoError ( t , encoder . Encode ( godep . DeviceResponse { Devices : [ ] godep . Device { teamDevice } , Cursor : "foo" } ) )
case "/profile/devices" :
b , err := io . ReadAll ( r . Body )
require . NoError ( t , err )
var prof profileAssignmentReq
require . NoError ( t , json . Unmarshal ( b , & prof ) )
resp := godep . ProfileResponse { ProfileUUID : prof . ProfileUUID }
resp . Devices = make ( map [ string ] string , len ( prof . Devices ) )
for _ , d := range prof . Devices {
resp . Devices [ d ] = string ( fleet . DEPAssignProfileResponseSuccess )
}
require . NoError ( t , encoder . Encode ( resp ) )
default :
_ , _ = w . Write ( [ ] byte ( ` { } ` ) )
}
} ) )
s . runDEPSchedule ( )
listHostsRes := listHostsResponse { }
s . DoJSON ( "GET" , "/api/latest/fleet/hosts" , nil , http . StatusOK , & listHostsRes )
require . Len ( t , listHostsRes . Hosts , 1 )
require . Equal ( t , teamDevice . SerialNumber , listHostsRes . Hosts [ 0 ] . HardwareSerial )
enrolledHost := listHostsRes . Hosts [ 0 ] . Host
enrolledHost . TeamID = & tm . ID
s . Do ( "POST" , "/api/v1/fleet/hosts/transfer" ,
addHostsToTeamRequest { TeamID : & tm . ID , HostIDs : [ ] uint { enrolledHost . ID } } , http . StatusOK )
// DEP enroll the MDM device.
depURLToken := loadEnrollmentProfileDEPToken ( t , s . ds )
mdmDevice := mdmtest . NewTestMDMClientAppleDEP ( s . server . URL , depURLToken )
mdmDevice . SerialNumber = teamDevice . SerialNumber
require . NoError ( t , mdmDevice . Enroll ( ) )
s . runWorker ( )
s . awaitTriggerProfileSchedule ( t )
// Drain the initial MDM commands (InstallProfile × 3 + InstallEnterpriseApplication × 1).
var cmds [ ] * micromdm . CommandPayload
cmd , err := mdmDevice . Idle ( )
require . NoError ( t , err )
for cmd != nil {
var fullCmd micromdm . CommandPayload
require . NoError ( t , plist . Unmarshal ( cmd . Raw , & fullCmd ) )
cmds = append ( cmds , & fullCmd )
cmd , err = mdmDevice . Acknowledge ( cmd . CommandUUID )
require . NoError ( t , err )
}
require . Len ( t , cmds , 4 ) // 3 InstallProfile + 1 InstallEnterpriseApplication (fleetd)
// Orbit-enroll the host (simulates fleetd being installed).
enrolledHost . OsqueryHostID = ptr . String ( mdmDevice . UUID )
enrolledHost . UUID = mdmDevice . UUID
orbitKey := setOrbitEnrollment ( t , enrolledHost , s . ds )
enrolledHost . OrbitNodeKey = & orbitKey
// No pending Release Device job yet.
pending , err := s . ds . GetQueuedJobs ( ctx , 1 , time . Now ( ) . UTC ( ) . Add ( time . Minute ) )
require . NoError ( t , err )
require . Len ( t , pending , 0 )
// First /status call: software pending, no script involved.
var statusResp getOrbitSetupExperienceStatusResponse
s . DoJSON ( "POST" , "/api/fleet/orbit/setup_experience/status" ,
json . RawMessage ( fmt . Sprintf ( ` { "orbit_node_key": %q} ` , * enrolledHost . OrbitNodeKey ) ) ,
http . StatusOK , & statusResp )
require . Nil ( t , statusResp . Results . BootstrapPackage )
require . Nil ( t , statusResp . Results . AccountConfiguration )
require . Len ( t , statusResp . Results . ConfigurationProfiles , 3 )
require . Nil ( t , statusResp . Results . Script )
require . Len ( t , statusResp . Results . Software , 1 )
fmaResult := statusResp . Results . Software [ 0 ]
require . Equal ( t , "1Password" , fmaResult . Name )
require . Equal ( t , fleet . SetupExperienceStatusPending , fmaResult . Status )
require . NotNil ( t , fmaResult . SoftwareTitleID )
require . Equal ( t , fmaTitleID , * fmaResult . SoftwareTitleID )
// Pull the execution ID out of the DB (the status endpoint doesn't surface it).
results , err := s . ds . ListSetupExperienceResultsByHostUUID ( ctx , enrolledHost . UUID )
require . NoError ( t , err )
require . Len ( t , results , 1 )
require . NotNil ( t , results [ 0 ] . HostSoftwareInstallsExecutionID )
installUUID := * results [ 0 ] . HostSoftwareInstallsExecutionID
require . NotEmpty ( t , installUUID )
// Retrieve the title so we can read the active package name (should be v1.0).
var titleDetail getSoftwareTitleResponse
s . DoJSON ( "GET" , fmt . Sprintf ( "/api/latest/fleet/software/titles/%d" , fmaTitleID ) ,
nil , http . StatusOK , & titleDetail ,
"team_id" , fmt . Sprint ( tm . ID ) )
require . NotNil ( t , titleDetail . SoftwareTitle )
require . NotNil ( t , titleDetail . SoftwareTitle . SoftwarePackage )
require . Equal ( t , "1.0" , titleDetail . SoftwareTitle . SoftwarePackage . Version ,
"the installed version during setup experience must be the rolled-back v1.0" )
// No MDM command was enqueued just from the /status call (device not released yet).
cmd , err = mdmDevice . Idle ( )
require . NoError ( t , err )
require . Nil ( t , cmd )
// Second /status call: software transitions to running.
statusResp = getOrbitSetupExperienceStatusResponse { }
s . DoJSON ( "POST" , "/api/fleet/orbit/setup_experience/status" ,
json . RawMessage ( fmt . Sprintf ( ` { "orbit_node_key": %q} ` , * enrolledHost . OrbitNodeKey ) ) ,
http . StatusOK , & statusResp )
require . Len ( t , statusResp . Results . Software , 1 )
require . Equal ( t , "1Password" , statusResp . Results . Software [ 0 ] . Name )
require . Equal ( t , fleet . SetupExperienceStatusRunning , statusResp . Results . Software [ 0 ] . Status )
// Verify the upcoming activity references the rolled-back v1.0 package.
var hostActivitiesResp listHostUpcomingActivitiesResponse
s . DoJSON ( "GET" , fmt . Sprintf ( "/api/latest/fleet/hosts/%d/activities/upcoming" , enrolledHost . ID ) ,
nil , http . StatusOK , & hostActivitiesResp )
require . Len ( t , hostActivitiesResp . Activities , 1 )
require . NotNil ( t , hostActivitiesResp . Activities [ 0 ] . Details )
var activityDetails map [ string ] any
require . NoError ( t , json . Unmarshal ( * hostActivitiesResp . Activities [ 0 ] . Details , & activityDetails ) )
require . Equal ( t , installUUID , activityDetails [ "install_uuid" ] )
require . Equal ( t , "1Password" , activityDetails [ "software_title" ] )
// The package name must come from the v1.0 installer, not v2.0.
require . Equal ( t , titleDetail . SoftwareTitle . SoftwarePackage . Name , activityDetails [ "software_package" ] )
// Post a successful install result for the FMA.
s . Do ( "POST" , "/api/fleet/orbit/software_install/result" ,
json . RawMessage ( fmt . Sprintf ( ` {
"orbit_node_key": %q,
"install_uuid": %q,
"install_script_exit_code": 0,
"install_script_output": "ok"
} ` , * enrolledHost . OrbitNodeKey , installUUID ) ) , http . StatusNoContent )
// /status call after success: software is now complete.
statusResp = getOrbitSetupExperienceStatusResponse { }
s . DoJSON ( "POST" , "/api/fleet/orbit/setup_experience/status" ,
json . RawMessage ( fmt . Sprintf ( ` { "orbit_node_key": %q} ` , * enrolledHost . OrbitNodeKey ) ) ,
http . StatusOK , & statusResp )
require . Nil ( t , statusResp . Results . BootstrapPackage )
require . Nil ( t , statusResp . Results . AccountConfiguration )
require . Nil ( t , statusResp . Results . Script )
require . Len ( t , statusResp . Results . Software , 1 )
require . Equal ( t , "1Password" , statusResp . Results . Software [ 0 ] . Name )
require . Equal ( t , fleet . SetupExperienceStatusSuccess , statusResp . Results . Software [ 0 ] . Status )
// The device should now receive a DeviceConfigured command (auto-release).
cmd , err = mdmDevice . Idle ( )
require . NoError ( t , err )
cmds = cmds [ : 0 ]
for cmd != nil {
var fullCmd micromdm . CommandPayload
require . NoError ( t , plist . Unmarshal ( cmd . Raw , & fullCmd ) )
cmds = append ( cmds , & fullCmd )
cmd , err = mdmDevice . Acknowledge ( cmd . CommandUUID )
require . NoError ( t , err )
}
require . Len ( t , cmds , 1 )
require . Equal ( t , "DeviceConfigured" , cmds [ 0 ] . Command . RequestType )
// Verify the installed-software activity references the rolled-back v1.0 package.
var getHostResp getHostResponse
s . DoJSON ( "GET" , fmt . Sprintf ( "/api/latest/fleet/hosts/%d" , enrolledHost . ID ) , nil , http . StatusOK , & getHostResp )
expectedActivityDetail := fmt . Sprintf ( `
{
"host_id": %d,
"host_display_name": "%s",
"software_title": "1Password",
"software_package": "%s",
"self_service": false,
"install_uuid": "%s",
"status": "installed",
"source": "apps",
"policy_id": null,
"policy_name": null
}
` , enrolledHost . ID , getHostResp . Host . DisplayName , titleDetail . SoftwareTitle . SoftwarePackage . Name , installUUID )
s . lastActivityMatchesExtended ( fleet . ActivityTypeInstalledSoftware { } . ActivityName ( ) , expectedActivityDetail , 0 , ptr . Bool ( true ) )
}
func ( s * integrationMDMTestSuite ) TestSetupExperienceFlowWithSoftwareAndScriptForceRelease ( ) {
t := s . T ( )
ctx := context . Background ( )