From 8774833a5441b5b49ad2ecdde88fe36dc74aecce Mon Sep 17 00:00:00 2001 From: Tim Lee Date: Fri, 19 Dec 2025 16:18:24 -0700 Subject: [PATCH] Handle android certificates on deletion events (#37481) --- ee/server/service/teams.go | 11 + .../datastore/mysql/certificate_templates.go | 4 + .../mysql/host_certificate_templates.go | 31 +++ .../mysql/host_certificate_templates_test.go | 108 ++++++++++ server/datastore/mysql/teams.go | 1 + server/fleet/datastore.go | 5 + server/mock/datastore_mock.go | 12 ++ server/service/certificates.go | 4 + server/service/integration_core_test.go | 169 ++++++++++++++- server/service/integration_enterprise_test.go | 196 ++++++++++++++++++ server/service/teams_test.go | 8 + 11 files changed, 548 insertions(+), 1 deletion(-) diff --git a/ee/server/service/teams.go b/ee/server/service/teams.go index 68cc62c798..00207042c7 100644 --- a/ee/server/service/teams.go +++ b/ee/server/service/teams.go @@ -703,6 +703,17 @@ func (svc *Service) DeleteTeam(ctx context.Context, teamID uint) error { } } + // Handle certificate templates associated with the team + certTemplates, _, err := svc.ds.GetCertificateTemplatesByTeamID(ctx, teamID, fleet.ListOptions{}) + if err != nil { + return ctxerr.Wrap(ctx, err, "get certificate templates for team") + } + for _, ct := range certTemplates { + if err := svc.ds.SetHostCertificateTemplatesToPendingRemove(ctx, ct.ID); err != nil { + return ctxerr.Wrapf(ctx, err, "set hosts to pending remove for certificate template %d", ct.ID) + } + } + if err := svc.ds.DeleteTeam(ctx, teamID); err != nil { return err } diff --git a/server/datastore/mysql/certificate_templates.go b/server/datastore/mysql/certificate_templates.go index 623dc76cf4..8735689010 100644 --- a/server/datastore/mysql/certificate_templates.go +++ b/server/datastore/mysql/certificate_templates.go @@ -2,6 +2,7 @@ package mysql import ( "context" + "database/sql" "errors" "fmt" "strings" @@ -28,6 +29,9 @@ func (ds *Datastore) GetCertificateTemplateById(ctx context.Context, id uint) (* INNER JOIN certificate_authorities ON certificate_templates.certificate_authority_id = certificate_authorities.id WHERE certificate_templates.id = ? `, id); err != nil { + if errors.Is(err, sql.ErrNoRows) { + return nil, ctxerr.Wrap(ctx, notFound("CertificateTemplate").WithID(id)) + } return nil, ctxerr.Wrap(ctx, err, "getting certificate_template by id") } diff --git a/server/datastore/mysql/host_certificate_templates.go b/server/datastore/mysql/host_certificate_templates.go index 7e794e266e..df706993be 100644 --- a/server/datastore/mysql/host_certificate_templates.go +++ b/server/datastore/mysql/host_certificate_templates.go @@ -457,3 +457,34 @@ func (ds *Datastore) RevertStaleCertificateTemplates( } return result.RowsAffected() } + +// SetHostCertificateTemplatesToPendingRemove prepares certificate templates for removal. +// For a given certificate template ID, it deletes any rows with status=pending and +// updates all other rows to status=pending, operation_type=remove. +func (ds *Datastore) SetHostCertificateTemplatesToPendingRemove( + ctx context.Context, + certificateTemplateID uint, +) error { + return ds.withRetryTxx(ctx, func(tx sqlx.ExtContext) error { + // Delete rows with status=pending + deleteStmt := fmt.Sprintf(` + DELETE FROM host_certificate_templates + WHERE certificate_template_id = ? AND status = '%s' + `, fleet.CertificateTemplatePending) + if _, err := tx.ExecContext(ctx, deleteStmt, certificateTemplateID); err != nil { + return ctxerr.Wrap(ctx, err, "delete pending host certificate templates") + } + + // Update all remaining rows to status=pending, operation_type=remove + updateStmt := fmt.Sprintf(` + UPDATE host_certificate_templates + SET status = '%s', operation_type = '%s' + WHERE certificate_template_id = ? + `, fleet.CertificateTemplatePending, fleet.MDMOperationTypeRemove) + if _, err := tx.ExecContext(ctx, updateStmt, certificateTemplateID); err != nil { + return ctxerr.Wrap(ctx, err, "update host certificate templates to pending remove") + } + + return nil + }) +} diff --git a/server/datastore/mysql/host_certificate_templates_test.go b/server/datastore/mysql/host_certificate_templates_test.go index b4750f094a..99992b534c 100644 --- a/server/datastore/mysql/host_certificate_templates_test.go +++ b/server/datastore/mysql/host_certificate_templates_test.go @@ -30,6 +30,7 @@ func TestHostCertificateTemplates(t *testing.T) { {"ListAndroidHostUUIDsWithPendingCertificateTemplates", testListAndroidHostUUIDsWithPendingCertificateTemplates}, {"CertificateTemplateFullStateMachine", testCertificateTemplateFullStateMachine}, {"RevertStaleCertificateTemplates", testRevertStaleCertificateTemplates}, + {"SetHostCertificateTemplatesToPendingRemove", testSetHostCertificateTemplatesToPendingRemove}, } for _, c := range cases { @@ -965,3 +966,110 @@ func testRevertStaleCertificateTemplates(t *testing.T, ds *Datastore) { require.Equal(t, int64(0), affected) }) } + +func testSetHostCertificateTemplatesToPendingRemove(t *testing.T, ds *Datastore) { + ctx := t.Context() + + t.Run("deletes pending rows and updates others to pending remove", func(t *testing.T) { + defer TruncateTables(t, ds) + setup := createCertTemplateTestSetup(t, ctx, ds, "") + + // Insert records with various statuses for the same template + _, err := ds.writer(ctx).ExecContext(ctx, ` + INSERT INTO host_certificate_templates (host_uuid, certificate_template_id, status, operation_type, fleet_challenge, name) VALUES + (?, ?, ?, ?, ?, ?), + (?, ?, ?, ?, ?, ?), + (?, ?, ?, ?, ?, ?), + (?, ?, ?, ?, ?, ?) + `, + "host-pending", setup.template.ID, fleet.CertificateTemplatePending, fleet.MDMOperationTypeInstall, nil, setup.template.Name, + "host-delivered", setup.template.ID, fleet.CertificateTemplateDelivered, fleet.MDMOperationTypeInstall, "challenge1", setup.template.Name, + "host-verified", setup.template.ID, fleet.CertificateTemplateVerified, fleet.MDMOperationTypeInstall, "challenge2", setup.template.Name, + "host-failed", setup.template.ID, fleet.CertificateTemplateFailed, fleet.MDMOperationTypeInstall, "challenge3", setup.template.Name, + ) + require.NoError(t, err) + + err = ds.SetHostCertificateTemplatesToPendingRemove(ctx, setup.template.ID) + require.NoError(t, err) + + // Verify the pending row was deleted + var count int + err = ds.writer(ctx).GetContext(ctx, &count, + "SELECT COUNT(*) FROM host_certificate_templates WHERE host_uuid = ?", "host-pending") + require.NoError(t, err) + require.Equal(t, 0, count) + + // Verify remaining rows have status=pending and operation_type=remove + var remaining []struct { + HostUUID string `db:"host_uuid"` + Status string `db:"status"` + OperationType fleet.MDMOperationType `db:"operation_type"` + } + err = ds.writer(ctx).SelectContext(ctx, &remaining, + "SELECT host_uuid, status, operation_type FROM host_certificate_templates ORDER BY host_uuid") + require.NoError(t, err) + require.Len(t, remaining, 3) + + for _, r := range remaining { + require.Equal(t, string(fleet.CertificateTemplatePending), r.Status) + require.Equal(t, fleet.MDMOperationTypeRemove, r.OperationType) + } + }) + + t.Run("only affects rows for the specified template", func(t *testing.T) { + defer TruncateTables(t, ds) + setup := createCertTemplateTestSetup(t, ctx, ds, "") + + // Create a second template + templateTwo, err := ds.CreateCertificateTemplate(ctx, &fleet.CertificateTemplate{ + Name: "Cert2", + TeamID: setup.team.ID, + CertificateAuthorityID: setup.ca.ID, + SubjectName: "CN=Test2", + }) + require.NoError(t, err) + + // Insert records for both templates + _, err = ds.writer(ctx).ExecContext(ctx, ` + INSERT INTO host_certificate_templates (host_uuid, certificate_template_id, status, operation_type, fleet_challenge, name) VALUES + (?, ?, ?, ?, ?, ?), + (?, ?, ?, ?, ?, ?) + `, + "host-1", setup.template.ID, fleet.CertificateTemplateDelivered, fleet.MDMOperationTypeInstall, "challenge1", setup.template.Name, + "host-1", templateTwo.ID, fleet.CertificateTemplateDelivered, fleet.MDMOperationTypeInstall, "challenge2", templateTwo.Name, + ) + require.NoError(t, err) + + // Call the method for template one only + err = ds.SetHostCertificateTemplatesToPendingRemove(ctx, setup.template.ID) + require.NoError(t, err) + + // Verify template one was updated + var row struct { + Status string `db:"status"` + OperationType fleet.MDMOperationType `db:"operation_type"` + } + err = ds.writer(ctx).GetContext(ctx, &row, + "SELECT status, operation_type FROM host_certificate_templates WHERE certificate_template_id = ?", + setup.template.ID) + require.NoError(t, err) + require.Equal(t, string(fleet.CertificateTemplatePending), row.Status) + require.Equal(t, fleet.MDMOperationTypeRemove, row.OperationType) + + // Verify template two was NOT affected + err = ds.writer(ctx).GetContext(ctx, &row, + "SELECT status, operation_type FROM host_certificate_templates WHERE certificate_template_id = ?", + templateTwo.ID) + require.NoError(t, err) + require.Equal(t, string(fleet.CertificateTemplateDelivered), row.Status) + require.Equal(t, fleet.MDMOperationTypeInstall, row.OperationType) + }) + + t.Run("handles no matching rows gracefully", func(t *testing.T) { + defer TruncateTables(t, ds) + + // Call with a non-existent template ID + err := ds.SetHostCertificateTemplatesToPendingRemove(ctx, 99999) + require.NoError(t, err) + }) +} diff --git a/server/datastore/mysql/teams.go b/server/datastore/mysql/teams.go index 56ff6dec6f..35ddfc1586 100644 --- a/server/datastore/mysql/teams.go +++ b/server/datastore/mysql/teams.go @@ -132,6 +132,7 @@ var teamRefs = []string{ "mdm_windows_configuration_profiles", "mdm_apple_declarations", "mdm_android_configuration_profiles", + "certificate_templates", "software_title_icons", "software_title_display_names", } diff --git a/server/fleet/datastore.go b/server/fleet/datastore.go index 9795cebc36..b5538d9172 100644 --- a/server/fleet/datastore.go +++ b/server/fleet/datastore.go @@ -2593,6 +2593,11 @@ type Datastore interface { // RevertHostCertificateTemplatesToPending reverts specific host certificate templates from 'delivering' back to 'pending'. RevertHostCertificateTemplatesToPending(ctx context.Context, hostUUID string, certificateTemplateIDs []uint) error + // SetHostCertificateTemplatesToPendingRemove prepares certificate templates for removal. + // For a given certificate template ID, it deletes any rows with status=pending and + // updates all other rows to status=pending, operation_type=remove. + SetHostCertificateTemplatesToPendingRemove(ctx context.Context, certificateTemplateID uint) error + // GetCurrentTime gets the current time from the database GetCurrentTime(ctx context.Context) (time.Time, error) diff --git a/server/mock/datastore_mock.go b/server/mock/datastore_mock.go index e49c68f6dd..a068cc7121 100644 --- a/server/mock/datastore_mock.go +++ b/server/mock/datastore_mock.go @@ -1695,6 +1695,8 @@ type TransitionCertificateTemplatesToDeliveredFunc func(ctx context.Context, hos type RevertHostCertificateTemplatesToPendingFunc func(ctx context.Context, hostUUID string, certificateTemplateIDs []uint) error +type SetHostCertificateTemplatesToPendingRemoveFunc func(ctx context.Context, certificateTemplateID uint) error + type GetCurrentTimeFunc func(ctx context.Context) (time.Time, error) type UpdateOrDeleteHostMDMWindowsProfileFunc func(ctx context.Context, profile *fleet.HostMDMWindowsProfile) error @@ -4212,6 +4214,9 @@ type DataStore struct { RevertHostCertificateTemplatesToPendingFunc RevertHostCertificateTemplatesToPendingFunc RevertHostCertificateTemplatesToPendingFuncInvoked bool + SetHostCertificateTemplatesToPendingRemoveFunc SetHostCertificateTemplatesToPendingRemoveFunc + SetHostCertificateTemplatesToPendingRemoveFuncInvoked bool + GetCurrentTimeFunc GetCurrentTimeFunc GetCurrentTimeFuncInvoked bool @@ -10079,6 +10084,13 @@ func (s *DataStore) RevertHostCertificateTemplatesToPending(ctx context.Context, return s.RevertHostCertificateTemplatesToPendingFunc(ctx, hostUUID, certificateTemplateIDs) } +func (s *DataStore) SetHostCertificateTemplatesToPendingRemove(ctx context.Context, certificateTemplateID uint) error { + s.mu.Lock() + s.SetHostCertificateTemplatesToPendingRemoveFuncInvoked = true + s.mu.Unlock() + return s.SetHostCertificateTemplatesToPendingRemoveFunc(ctx, certificateTemplateID) +} + func (s *DataStore) GetCurrentTime(ctx context.Context) (time.Time, error) { s.mu.Lock() s.GetCurrentTimeFuncInvoked = true diff --git a/server/service/certificates.go b/server/service/certificates.go index e31221dea8..a75af609d8 100644 --- a/server/service/certificates.go +++ b/server/service/certificates.go @@ -283,6 +283,10 @@ func (svc *Service) DeleteCertificateTemplate(ctx context.Context, certificateTe return ctxerr.Wrap(ctx, err, "deleting certificate template") } + if err := svc.ds.SetHostCertificateTemplatesToPendingRemove(ctx, certificateTemplateID); err != nil { + return ctxerr.Wrap(ctx, err, "setting host certificate templates to pending remove") + } + activity := fleet.ActivityTypeDeletedCertificate{ Name: certificate.Name, } diff --git a/server/service/integration_core_test.go b/server/service/integration_core_test.go index a5d0a2bae4..a9f5657975 100644 --- a/server/service/integration_core_test.go +++ b/server/service/integration_core_test.go @@ -8102,7 +8102,7 @@ func (s *integrationTestSuite) TestCertificatesSpecs() { }) require.NoError(t, json.NewDecoder(resp.Body).Decode(&getCertResp)) require.NoError(t, resp.Body.Close()) - require.Equal(t, getCertResp.Certificate.Status, fleet.CertificateTemplateFailed) + require.Equal(t, getCertResp.Certificate.Status, fleet.CertificateTemplateFailed) // failed because no IDP user to replace variables // Add an IDP user for the host err = s.ds.ReplaceHostDeviceMapping(ctx, host.ID, []*fleet.HostDeviceMapping{ @@ -15065,3 +15065,170 @@ INSERT INTO host_certificate_templates ( }) } } + +func (s *integrationTestSuite) TestDeleteCertificateTemplate() { + t := s.T() + ctx := context.Background() + + // Create a test team + team, err := s.ds.NewTeam(ctx, &fleet.Team{Name: "TestDeleteCertificateTemplate 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("TestDeleteCertificateTemplate SCEP CA"), + URL: ptr.String("http://localhost:8080/scep"), + Challenge: ptr.String("test-challenge"), + }) + require.NoError(t, err) + caID := ca.ID + + certTemplate := &fleet.CertificateTemplate{ + Name: "TestDeleteCertificateTemplate-Cert", + TeamID: teamID, + CertificateAuthorityID: caID, + SubjectName: "CN=Test Subject", + } + savedTemplate, err := s.ds.CreateCertificateTemplate(ctx, certTemplate) + require.NoError(t, err) + require.NotNil(t, savedTemplate) + certificateTemplateID := savedTemplate.ID + certTemplateName := savedTemplate.Name + + // Create hosts with different certificate template statuses + hostPending, err := s.ds.NewHost(ctx, &fleet.Host{ + UUID: uuid.New().String(), + Hostname: "test-delete-cert-template-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-template-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-template-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-template-host-failed", + Platform: "android", + TeamID: &teamID, + }) + require.NoError(t, err) + + // Insert host_certificate_templates with various statuses + 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 { + // Pending status - should be deleted + _, err := q.ExecContext(ctx, insertSQL, hostPending.UUID, certificateTemplateID, "pending", "install", nil, certTemplateName) + require.NoError(t, err) + // Delivered status - should be updated to pending/remove + _, err = q.ExecContext(ctx, insertSQL, hostDelivered.UUID, certificateTemplateID, "delivered", "install", "challenge1", certTemplateName) + require.NoError(t, err) + // Verified status - should be updated to pending/remove + _, err = q.ExecContext(ctx, insertSQL, hostVerified.UUID, certificateTemplateID, "verified", "install", "challenge2", certTemplateName) + require.NoError(t, err) + // Failed status - should be updated to pending/remove + _, err = q.ExecContext(ctx, insertSQL, hostFailed.UUID, certificateTemplateID, "failed", "install", "challenge3", certTemplateName) + 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 + }{ + {hostPending, "hostPending", string(fleet.CertificateTemplatePending)}, + {hostDelivered, "hostDelivered", string(fleet.CertificateTemplateDelivered)}, + {hostVerified, "hostVerified", string(fleet.CertificateTemplateVerified)}, + {hostFailed, "hostFailed", string(fleet.CertificateTemplateFailed)}, + } { + 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, certTemplateName) + require.NotNil(t, profile, "%s should have certificate template profile %s before deletion", tc.hostName, certTemplateName) + 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 the certificate template via API + var deleteResp deleteCertificateTemplateResponse + s.DoJSON("DELETE", fmt.Sprintf("/api/latest/fleet/certificates/%d", certificateTemplateID), nil, http.StatusOK, &deleteResp) + + // 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, certTemplateName) + 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 + }{ + {hostDelivered, "hostDelivered"}, + {hostVerified, "hostVerified"}, + {hostFailed, "hostFailed"}, + } { + s.DoJSON("GET", fmt.Sprintf("/api/latest/fleet/hosts/%d", tc.host.ID), nil, http.StatusOK, &getHostResp) + profile := findProfile(getHostResp.Host.MDM.Profiles, certTemplateName) + 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) + } +} diff --git a/server/service/integration_enterprise_test.go b/server/service/integration_enterprise_test.go index 4d3c0a7d2b..1a909cfdd2 100644 --- a/server/service/integration_enterprise_test.go +++ b/server/service/integration_enterprise_test.go @@ -22981,3 +22981,199 @@ func (s *integrationEnterpriseTestSuite) TestTeamLabelsDistributedReadWrite() { }) require.NoError(t, err) } + +func (s *integrationEnterpriseTestSuite) TestDeleteTeamCertificateTemplates() { + t := s.T() + ctx := context.Background() + + // Create a test team + team, err := s.ds.NewTeam(ctx, &fleet.Team{Name: "TestDeleteTeamCertificateTemplates 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("TestDeleteTeamCertificateTemplates 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 on the team + certTemplate1 := &fleet.CertificateTemplate{ + Name: "TestDeleteTeamCertificateTemplates-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) + certificateTemplateID1 := savedTemplate1.ID + certTemplateName1 := savedTemplate1.Name + + certTemplate2 := &fleet.CertificateTemplate{ + Name: "TestDeleteTeamCertificateTemplates-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) + certificateTemplateID2 := 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-team-cert-template-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-team-cert-template-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-team-cert-template-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-team-cert-template-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 - Various statuses + // Pending status - should be deleted + _, err := q.ExecContext(ctx, insertSQL, hostPending.UUID, certificateTemplateID1, "pending", "install", nil, certTemplateName1) + require.NoError(t, err) + // Delivered status - should be updated to pending/remove + _, err = q.ExecContext(ctx, insertSQL, hostDelivered.UUID, certificateTemplateID1, "delivered", "install", "challenge1", certTemplateName1) + require.NoError(t, err) + + // Template 2 - Various statuses + // Verified status - should be updated to pending/remove + _, err = q.ExecContext(ctx, insertSQL, hostVerified.UUID, certificateTemplateID2, "verified", "install", "challenge2", certTemplateName2) + require.NoError(t, err) + // Failed status - should be updated to pending/remove + _, err = q.ExecContext(ctx, insertSQL, hostFailed.UUID, certificateTemplateID2, "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) + // Wait for cache to expire (default 1 second) + time.Sleep(2 * time.Second) + 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 team 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 the team via API (this should delete all certificate templates on the team) + var deleteResp deleteTeamResponse + s.DoJSON("DELETE", fmt.Sprintf("/api/latest/fleet/teams/%d", teamID), nil, http.StatusOK, &deleteResp) + + // Verify the certificate templates were deleted + _, err = s.ds.GetCertificateTemplateById(ctx, certificateTemplateID1) + require.Error(t, err) + require.True(t, fleet.IsNotFound(err), "certificate template 1 should be deleted") + + _, err = s.ds.GetCertificateTemplateById(ctx, certificateTemplateID2) + require.Error(t, err) + require.True(t, fleet.IsNotFound(err), "certificate template 2 should be deleted") + + // After team 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) + } +} diff --git a/server/service/teams_test.go b/server/service/teams_test.go index 7863cca2d0..61bab5f452 100644 --- a/server/service/teams_test.go +++ b/server/service/teams_test.go @@ -79,6 +79,14 @@ func TestTeamAuth(t *testing.T) { return nil, ¬FoundError{} } + ds.GetCertificateTemplatesByTeamIDFunc = func(ctx context.Context, teamID uint, opts fleet.ListOptions) ([]*fleet.CertificateTemplateResponseSummary, *fleet.PaginationMetadata, error) { + return []*fleet.CertificateTemplateResponseSummary{}, nil, nil + } + + ds.SetHostCertificateTemplatesToPendingRemoveFunc = func(ctx context.Context, teamID uint) error { + return nil + } + testCases := []struct { name string user *fleet.User