diff --git a/cmd/fleetctl/fleetctl/gitops_test.go b/cmd/fleetctl/fleetctl/gitops_test.go index d0762accad..35d6b8ce8d 100644 --- a/cmd/fleetctl/fleetctl/gitops_test.go +++ b/cmd/fleetctl/fleetctl/gitops_test.go @@ -4031,6 +4031,10 @@ func TestGitOpsAndroidCertificatesChange(t *testing.T) { return true, nil } + ds.SetHostCertificateTemplatesToPendingRemoveFunc = func(ctx context.Context, certificateTemplateIDs uint) error { + return nil + } + // Create team config with modified subjectNames teamConfig := ` name: %s @@ -4254,6 +4258,10 @@ func TestGitOpsAndroidCertificatesDeleteOne(t *testing.T) { return existing, &fleet.PaginationMetadata{}, nil } + ds.SetHostCertificateTemplatesToPendingRemoveFunc = func(ctx context.Context, certificateTemplateIDs uint) error { + return nil + } + // Create team config with only one certificate (Certificate 1 removed) teamConfig := ` name: %s @@ -4346,6 +4354,10 @@ func TestGitOpsAndroidCertificatesDeleteAll(t *testing.T) { return existing, &fleet.PaginationMetadata{}, nil } + ds.SetHostCertificateTemplatesToPendingRemoveFunc = func(ctx context.Context, certificateTemplateIDs uint) error { + return nil + } + // Create team config with no certificates teamConfig := ` name: %s diff --git a/server/service/certificates.go b/server/service/certificates.go index 458b79c188..8c98255b38 100644 --- a/server/service/certificates.go +++ b/server/service/certificates.go @@ -491,6 +491,13 @@ func (svc *Service) DeleteCertificateTemplateSpecs(ctx context.Context, certific return nil } + // Delete or mark the certificate templates as pending removal for all android hosts + for _, certificateTemplateID := range certificateTemplateIDs { + if err := svc.ds.SetHostCertificateTemplatesToPendingRemove(ctx, certificateTemplateID); err != nil { + return ctxerr.Wrap(ctx, err, "setting host certificate templates to pending remove") + } + } + // Only create activity if rows were actually deleted var tmID *uint var tmName *string diff --git a/server/service/integration_core_test.go b/server/service/integration_core_test.go index a9f5657975..eac6214b08 100644 --- a/server/service/integration_core_test.go +++ b/server/service/integration_core_test.go @@ -15232,3 +15232,196 @@ func (s *integrationTestSuite) TestDeleteCertificateTemplate() { require.Equal(t, fleet.MDMOperationTypeRemove, profile.OperationType, "%s profile operation_type should be remove after deletion", tc.hostName) } } + +func (s *integrationTestSuite) TestDeleteCertificateTemplateSpec() { + t := s.T() + ctx := context.Background() + + // Create a test team + team, err := s.ds.NewTeam(ctx, &fleet.Team{Name: "TestDeleteCertificateTemplateSpec Team"}) + require.NoError(t, err) + teamID := team.ID + + // Create a test certificate authority + ca, err := s.ds.NewCertificateAuthority(ctx, &fleet.CertificateAuthority{ + Type: string(fleet.CATypeCustomSCEPProxy), + Name: ptr.String("TestDeleteCertificateTemplateSpec SCEP CA"), + URL: ptr.String("http://localhost:8080/scep"), + Challenge: ptr.String("test-challenge"), + }) + require.NoError(t, err) + caID := ca.ID + + // Create two certificate templates + certTemplate1 := &fleet.CertificateTemplate{ + Name: "TestDeleteCertificateTemplateSpec-Cert1", + TeamID: teamID, + CertificateAuthorityID: caID, + SubjectName: "CN=Test Subject 1", + } + savedTemplate1, err := s.ds.CreateCertificateTemplate(ctx, certTemplate1) + require.NoError(t, err) + require.NotNil(t, savedTemplate1) + certTemplateID1 := savedTemplate1.ID + certTemplateName1 := savedTemplate1.Name + + certTemplate2 := &fleet.CertificateTemplate{ + Name: "TestDeleteCertificateTemplateSpec-Cert2", + TeamID: teamID, + CertificateAuthorityID: caID, + SubjectName: "CN=Test Subject 2", + } + savedTemplate2, err := s.ds.CreateCertificateTemplate(ctx, certTemplate2) + require.NoError(t, err) + require.NotNil(t, savedTemplate2) + certTemplateID2 := savedTemplate2.ID + certTemplateName2 := savedTemplate2.Name + + // Create hosts with different certificate template statuses + hostPending, err := s.ds.NewHost(ctx, &fleet.Host{ + UUID: uuid.New().String(), + Hostname: "test-delete-cert-spec-host-pending", + Platform: "android", + TeamID: &teamID, + }) + require.NoError(t, err) + + hostDelivered, err := s.ds.NewHost(ctx, &fleet.Host{ + UUID: uuid.New().String(), + Hostname: "test-delete-cert-spec-host-delivered", + Platform: "android", + TeamID: &teamID, + }) + require.NoError(t, err) + + hostVerified, err := s.ds.NewHost(ctx, &fleet.Host{ + UUID: uuid.New().String(), + Hostname: "test-delete-cert-spec-host-verified", + Platform: "android", + TeamID: &teamID, + }) + require.NoError(t, err) + + hostFailed, err := s.ds.NewHost(ctx, &fleet.Host{ + UUID: uuid.New().String(), + Hostname: "test-delete-cert-spec-host-failed", + Platform: "android", + TeamID: &teamID, + }) + require.NoError(t, err) + + // Insert host_certificate_templates with various statuses for both templates + insertSQL := ` + INSERT INTO host_certificate_templates (host_uuid, certificate_template_id, status, operation_type, fleet_challenge, name) + VALUES (?, ?, ?, ?, ?, ?) + ` + mysql.ExecAdhocSQL(t, s.ds, func(q sqlx.ExtContext) error { + // Template 1 - hosts with pending and delivered status + _, err := q.ExecContext(ctx, insertSQL, hostPending.UUID, certTemplateID1, "pending", "install", nil, certTemplateName1) + require.NoError(t, err) + _, err = q.ExecContext(ctx, insertSQL, hostDelivered.UUID, certTemplateID1, "delivered", "install", "challenge1", certTemplateName1) + require.NoError(t, err) + + // Template 2 - hosts with verified and failed status + _, err = q.ExecContext(ctx, insertSQL, hostVerified.UUID, certTemplateID2, "verified", "install", "challenge2", certTemplateName2) + require.NoError(t, err) + _, err = q.ExecContext(ctx, insertSQL, hostFailed.UUID, certTemplateID2, "failed", "install", "challenge3", certTemplateName2) + require.NoError(t, err) + return nil + }) + + // Enable Android MDM so GetHost returns certificate template profiles + appCfg, err := s.ds.AppConfig(ctx) + require.NoError(t, err) + origAndroidEnabled := appCfg.MDM.AndroidEnabledAndConfigured + appCfg.MDM.AndroidEnabledAndConfigured = true + err = s.ds.SaveAppConfig(ctx, appCfg) + require.NoError(t, err) + err = s.ds.SetAndroidEnabledAndConfigured(ctx, true) + require.NoError(t, err) + defer func() { + appCfg.MDM.AndroidEnabledAndConfigured = origAndroidEnabled + _ = s.ds.SaveAppConfig(ctx, appCfg) + _ = s.ds.SetAndroidEnabledAndConfigured(ctx, origAndroidEnabled) + }() + + // Helper to find the certificate template profile by name + findProfile := func(profiles *[]fleet.HostMDMProfile, name string) *fleet.HostMDMProfile { + if profiles == nil { + return nil + } + for _, p := range *profiles { + if p.Name == name { + return &p + } + } + return nil + } + + // Verify the records exist before deletion via GetHost API + var getHostResp getHostResponse + for _, tc := range []struct { + host *fleet.Host + hostName string + expectedStatus string + templateName string + }{ + {hostPending, "hostPending", string(fleet.CertificateTemplatePending), certTemplateName1}, + {hostDelivered, "hostDelivered", string(fleet.CertificateTemplateDelivered), certTemplateName1}, + {hostVerified, "hostVerified", string(fleet.CertificateTemplateVerified), certTemplateName2}, + {hostFailed, "hostFailed", string(fleet.CertificateTemplateFailed), certTemplateName2}, + } { + s.DoJSON("GET", fmt.Sprintf("/api/latest/fleet/hosts/%d", tc.host.ID), nil, http.StatusOK, &getHostResp) + require.NotNil(t, getHostResp.Host.MDM.Profiles, "%s should have MDM profiles before deletion", tc.hostName) + + profile := findProfile(getHostResp.Host.MDM.Profiles, tc.templateName) + require.NotNil(t, profile, "%s should have certificate template profile %s before deletion", tc.hostName, tc.templateName) + require.NotNil(t, profile.Status, "%s profile status should not be nil", tc.hostName) + require.Equal(t, tc.expectedStatus, *profile.Status, "%s profile status should be %s before deletion", tc.hostName, tc.expectedStatus) + require.Equal(t, fleet.MDMOperationTypeInstall, profile.OperationType, "%s profile operation_type should be install before deletion", tc.hostName) + } + + // Delete both certificate templates via spec endpoint (batch delete) + var delBatchResp deleteCertificateTemplateSpecsResponse + s.DoJSON("DELETE", "/api/latest/fleet/spec/certificates", map[string]any{ + "ids": []uint{certTemplateID1, certTemplateID2}, + "team_id": teamID, + }, http.StatusOK, &delBatchResp) + + // Verify certificate templates were deleted + _, err = s.ds.GetCertificateTemplateById(ctx, certTemplateID1) + require.Error(t, err) + require.True(t, fleet.IsNotFound(err), "certificate template 1 should be deleted") + + _, err = s.ds.GetCertificateTemplateById(ctx, certTemplateID2) + require.Error(t, err) + require.True(t, fleet.IsNotFound(err), "certificate template 2 should be deleted") + + // After deletion: + // - hostPending (pending/install) should have NO profile (record was deleted) + // - hostDelivered, hostVerified, hostFailed should have pending/remove profiles + // (kept for cron job to process removal from devices) + + // Verify hostPending has no profile after deletion + s.DoJSON("GET", fmt.Sprintf("/api/latest/fleet/hosts/%d", hostPending.ID), nil, http.StatusOK, &getHostResp) + profile := findProfile(getHostResp.Host.MDM.Profiles, certTemplateName1) + require.Nil(t, profile, "hostPending should not have certificate template profile after deletion") + + // Verify hosts that had delivered/verified/failed status now have pending/remove profiles + for _, tc := range []struct { + host *fleet.Host + hostName string + templateName string + }{ + {hostDelivered, "hostDelivered", certTemplateName1}, + {hostVerified, "hostVerified", certTemplateName2}, + {hostFailed, "hostFailed", certTemplateName2}, + } { + s.DoJSON("GET", fmt.Sprintf("/api/latest/fleet/hosts/%d", tc.host.ID), nil, http.StatusOK, &getHostResp) + profile := findProfile(getHostResp.Host.MDM.Profiles, tc.templateName) + require.NotNil(t, profile, "%s should have pending remove profile after deletion", tc.hostName) + require.NotNil(t, profile.Status, "%s profile status should not be nil", tc.hostName) + require.Equal(t, string(fleet.CertificateTemplatePending), *profile.Status, "%s profile status should be pending after deletion", tc.hostName) + require.Equal(t, fleet.MDMOperationTypeRemove, profile.OperationType, "%s profile operation_type should be remove after deletion", tc.hostName) + } +}