diff --git a/changes/41542-android-cert-resend-backend b/changes/41542-android-cert-resend-backend new file mode 100644 index 0000000000..3c357dbbff --- /dev/null +++ b/changes/41542-android-cert-resend-backend @@ -0,0 +1 @@ +- Add backend to resend Android certificates diff --git a/server/datastore/mysql/certificate_templates.go b/server/datastore/mysql/certificate_templates.go index 894294ab41..54dd213ed7 100644 --- a/server/datastore/mysql/certificate_templates.go +++ b/server/datastore/mysql/certificate_templates.go @@ -400,3 +400,30 @@ func (ds *Datastore) CreatePendingCertificateTemplatesForNewHost( } return result.RowsAffected() } + +func (ds *Datastore) ResendHostCertificateTemplate(ctx context.Context, hostID uint, templateID uint) error { + const stmt = ` + UPDATE + host_certificate_templates hct + INNER JOIN + hosts h ON h.uuid = hct.host_uuid + SET + hct.uuid = UUID_TO_BIN(UUID(), true), + hct.status = ? + WHERE + h.id = ? AND + hct.certificate_template_id = ? + ` + + results, err := ds.writer(ctx).ExecContext(ctx, stmt, fleet.CertificateTemplatePending, hostID, templateID) + if err != nil { + return ctxerr.Wrap(ctx, err, "updating host certificate template uuid") + } + + affected, _ := results.RowsAffected() + if affected == 0 { + return ctxerr.Wrapf(ctx, notFound("HostCertificateTemplate"), "template %d does not exist for host %d", templateID, hostID) + } + + return nil +} diff --git a/server/datastore/mysql/certificate_templates_test.go b/server/datastore/mysql/certificate_templates_test.go index a63930ffc3..e458a55b4b 100644 --- a/server/datastore/mysql/certificate_templates_test.go +++ b/server/datastore/mysql/certificate_templates_test.go @@ -30,6 +30,7 @@ func TestCertificates(t *testing.T) { {"GetHostCertificateTemplates", testGetHostCertificateTemplates}, {"GetCertificateTemplateForHost", testGetCertificateTemplateForHost}, {"GetHostCertificateTemplateRecord", testGetHostCertificateTemplateRecord}, + {"ResendHostCertificateTemplate", testResendHostCertificateTemplate}, } for _, c := range cases { @@ -1325,3 +1326,96 @@ func testGetHostCertificateTemplateRecord(t *testing.T, ds *Datastore) { require.Equal(t, fleet.CertificateTemplateDelivered, result.Status) }) } + +func testResendHostCertificateTemplate(t *testing.T, ds *Datastore) { + defer TruncateTables(t, ds) + + ctx := context.Background() + + team1, err := ds.NewTeam(ctx, &fleet.Team{Name: "Team 1"}) + require.NoError(t, err) + + h1 := test.NewHost(t, ds, "host_1", "127.0.0.1", "1", "1", time.Now()) + h1.TeamID = &team1.ID + err = ds.UpdateHost(ctx, h1) + require.NoError(t, err) + + ca, err := ds.NewCertificateAuthority(ctx, &fleet.CertificateAuthority{ + Type: string(fleet.CATypeCustomSCEPProxy), + Name: ptr.String("Test SCEP CA"), + URL: ptr.String("http://localhost:8080/scep"), + Challenge: ptr.String("test-challenge"), + }) + require.NoError(t, err) + + ct1, err := ds.CreateCertificateTemplate(ctx, &fleet.CertificateTemplate{ + Name: "Template1", + TeamID: team1.ID, + CertificateAuthorityID: ca.ID, + SubjectName: "CN=Test Subject 1", + }) + require.NoError(t, err) + + testCases := []struct { + name string + initialStatus fleet.CertificateTemplateStatus + }{ + {"from verified", fleet.CertificateTemplateVerified}, + {"from delivered", fleet.CertificateTemplateDelivered}, + {"from failed", fleet.CertificateTemplateFailed}, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + err = ds.BulkInsertHostCertificateTemplates(ctx, []fleet.HostCertificateTemplate{ + { + HostUUID: h1.UUID, + CertificateTemplateID: ct1.ID, + Status: tc.initialStatus, + OperationType: fleet.MDMOperationTypeInstall, + Name: "Template1", + }, + }) + require.NoError(t, err) + + originalRecord, err := ds.GetHostCertificateTemplateRecord(ctx, h1.UUID, ct1.ID) + require.NoError(t, err) + originalUUID := originalRecord.UUID + + err = ds.ResendHostCertificateTemplate(ctx, h1.ID, ct1.ID) + require.NoError(t, err) + + updated, err := ds.GetHostCertificateTemplateRecord(ctx, h1.UUID, ct1.ID) + require.NoError(t, err) + require.Equal(t, fleet.CertificateTemplatePending, updated.Status) + require.NotEqual(t, originalUUID, updated.UUID, "UUID should change after resend") + require.NotEmpty(t, updated.UUID) + + // Clean up for next subtest + err = ds.DeleteHostCertificateTemplate(ctx, h1.UUID, ct1.ID) + require.NoError(t, err) + }) + } + + t.Run("returns error for non-existent host", func(t *testing.T) { + err := ds.ResendHostCertificateTemplate(ctx, 99999, ct1.ID) + require.Error(t, err) + }) + + t.Run("returns error for non-existent template", func(t *testing.T) { + // Insert a record first so the host exists with some template + err = ds.BulkInsertHostCertificateTemplates(ctx, []fleet.HostCertificateTemplate{ + { + HostUUID: h1.UUID, + CertificateTemplateID: ct1.ID, + Status: fleet.CertificateTemplateVerified, + OperationType: fleet.MDMOperationTypeInstall, + Name: "Template1", + }, + }) + require.NoError(t, err) + + err := ds.ResendHostCertificateTemplate(ctx, h1.ID, 99999) + require.Error(t, err) + }) +} diff --git a/server/fleet/activities.go b/server/fleet/activities.go index c3dce69467..29fb9cb3a5 100644 --- a/server/fleet/activities.go +++ b/server/fleet/activities.go @@ -166,6 +166,7 @@ var ActivityDetailsList = []ActivityDetails{ ActivityTypeDeletedAndroidProfile{}, ActivityTypeEditedAndroidProfile{}, ActivityTypeEditedAndroidCertificate{}, + ActivityTypeResentCertificate{}, ActivityTypeResentConfigurationProfile{}, ActivityTypeResentConfigurationProfileBatch{}, @@ -1749,6 +1750,35 @@ func (a ActivityTypeEditedAndroidCertificate) ActivityName() string { return "edited_android_certificate" } +type ActivityTypeResentCertificate struct { + HostID uint `json:"host_id"` + HostDisplayName string `json:"host_display_name"` + CertificateTemplateID uint `json:"certificate_template_id"` + CertificateName string `json:"certificate_name"` +} + +func (a ActivityTypeResentCertificate) ActivityName() string { + return "resent_certificate" +} + +func (a ActivityTypeResentCertificate) HostIDs() []uint { + return []uint{a.HostID} +} + +func (a ActivityTypeResentCertificate) Documentation() (activity string, details string, detailsExample string) { + return `Generated when a user resends a certificate to a host.`, + `This activity contains the following fields: +- "host_id": The ID of the host. +- "host_display_name": The display name of the host. +- "certificate_template_id": The ID of the certificate template +- "certificate_name": The name of the certificate`, `{ + "host_id": 1, + "host_display_name": "Anna's MacBook Pro", + "certificate_template_id": 123, + "certificate_name": "Zero trust certificate" +}` +} + type ActivityTypeEditedHostIdpData struct { HostID uint `json:"host_id"` HostDisplayName string `json:"host_display_name"` diff --git a/server/fleet/datastore.go b/server/fleet/datastore.go index 2a49e18f04..fdbe3bb1ee 100644 --- a/server/fleet/datastore.go +++ b/server/fleet/datastore.go @@ -2744,6 +2744,8 @@ type Datastore interface { // DeleteHostCertificateTemplate deletes a single host_certificate_template record // identified by host_uuid and certificate_template_id. DeleteHostCertificateTemplate(ctx context.Context, hostUUID string, certificateTemplateID uint) error + // ResendHostCertificateTemplate queues a certificate template to be resent to a device + ResendHostCertificateTemplate(ctx context.Context, hostID uint, templateID uint) error // ListAndroidHostUUIDsWithPendingCertificateTemplates returns hosts that have // certificate templates in 'pending' status ready for delivery. diff --git a/server/fleet/service.go b/server/fleet/service.go index 98896f1901..f23ad3ac02 100644 --- a/server/fleet/service.go +++ b/server/fleet/service.go @@ -507,6 +507,7 @@ type Service interface { // ListHostCertificates lists the certificates installed on the specified host. ListHostCertificates(ctx context.Context, hostID uint, opts ListOptions) ([]*HostCertificatePayload, *PaginationMetadata, error) + // GetHostRecoveryLockPassword retrieves the recovery lock password for the specified host. // Requires admin or maintainer role and MDM to be enabled. GetHostRecoveryLockPassword(ctx context.Context, hostID uint) (*HostRecoveryLockPassword, error) @@ -688,6 +689,7 @@ type Service interface { ApplyCertificateTemplateSpecs(ctx context.Context, specs []*CertificateRequestSpec) error DeleteCertificateTemplateSpecs(ctx context.Context, certificateTemplateIDs []uint, teamID uint) error UpdateCertificateStatus(ctx context.Context, update *CertificateStatusUpdate) error + ResendHostCertificateTemplate(ctx context.Context, hostID uint, templateID uint) error // ///////////////////////////////////////////////////////////////////////////// // GlobalScheduleService diff --git a/server/mock/datastore_mock.go b/server/mock/datastore_mock.go index 3ec76327d5..33b5e92807 100644 --- a/server/mock/datastore_mock.go +++ b/server/mock/datastore_mock.go @@ -1797,6 +1797,8 @@ type DeleteHostCertificateTemplatesFunc func(ctx context.Context, hostCertTempla type DeleteHostCertificateTemplateFunc func(ctx context.Context, hostUUID string, certificateTemplateID uint) error +type ResendHostCertificateTemplateFunc func(ctx context.Context, hostID uint, templateID uint) error + type ListAndroidHostUUIDsWithPendingCertificateTemplatesFunc func(ctx context.Context, offset int, limit int) ([]string, error) type GetAndTransitionCertificateTemplatesToDeliveringFunc func(ctx context.Context, hostUUID string) (*fleet.HostCertificateTemplatesForDelivery, error) @@ -4493,6 +4495,9 @@ type DataStore struct { DeleteHostCertificateTemplateFunc DeleteHostCertificateTemplateFunc DeleteHostCertificateTemplateFuncInvoked bool + ResendHostCertificateTemplateFunc ResendHostCertificateTemplateFunc + ResendHostCertificateTemplateFuncInvoked bool + ListAndroidHostUUIDsWithPendingCertificateTemplatesFunc ListAndroidHostUUIDsWithPendingCertificateTemplatesFunc ListAndroidHostUUIDsWithPendingCertificateTemplatesFuncInvoked bool @@ -10756,6 +10761,13 @@ func (s *DataStore) DeleteHostCertificateTemplate(ctx context.Context, hostUUID return s.DeleteHostCertificateTemplateFunc(ctx, hostUUID, certificateTemplateID) } +func (s *DataStore) ResendHostCertificateTemplate(ctx context.Context, hostID uint, templateID uint) error { + s.mu.Lock() + s.ResendHostCertificateTemplateFuncInvoked = true + s.mu.Unlock() + return s.ResendHostCertificateTemplateFunc(ctx, hostID, templateID) +} + func (s *DataStore) ListAndroidHostUUIDsWithPendingCertificateTemplates(ctx context.Context, offset int, limit int) ([]string, error) { s.mu.Lock() s.ListAndroidHostUUIDsWithPendingCertificateTemplatesFuncInvoked = true diff --git a/server/mock/service/service_mock.go b/server/mock/service/service_mock.go index 42c3803534..65a2c87e54 100644 --- a/server/mock/service/service_mock.go +++ b/server/mock/service/service_mock.go @@ -421,6 +421,8 @@ type DeleteCertificateTemplateSpecsFunc func(ctx context.Context, certificateTem type UpdateCertificateStatusFunc func(ctx context.Context, update *fleet.CertificateStatusUpdate) error +type ResendHostCertificateTemplateFunc func(ctx context.Context, hostID uint, templateID uint) error + type GlobalScheduleQueryFunc func(ctx context.Context, sq *fleet.ScheduledQuery) (*fleet.ScheduledQuery, error) type GetGlobalScheduledQueriesFunc func(ctx context.Context, opts fleet.ListOptions) ([]*fleet.ScheduledQuery, error) @@ -1499,6 +1501,9 @@ type Service struct { UpdateCertificateStatusFunc UpdateCertificateStatusFunc UpdateCertificateStatusFuncInvoked bool + ResendHostCertificateTemplateFunc ResendHostCertificateTemplateFunc + ResendHostCertificateTemplateFuncInvoked bool + GlobalScheduleQueryFunc GlobalScheduleQueryFunc GlobalScheduleQueryFuncInvoked bool @@ -3620,6 +3625,13 @@ func (s *Service) UpdateCertificateStatus(ctx context.Context, update *fleet.Cer return s.UpdateCertificateStatusFunc(ctx, update) } +func (s *Service) ResendHostCertificateTemplate(ctx context.Context, hostID uint, templateID uint) error { + s.mu.Lock() + s.ResendHostCertificateTemplateFuncInvoked = true + s.mu.Unlock() + return s.ResendHostCertificateTemplateFunc(ctx, hostID, templateID) +} + func (s *Service) GlobalScheduleQuery(ctx context.Context, sq *fleet.ScheduledQuery) (*fleet.ScheduledQuery, error) { s.mu.Lock() s.GlobalScheduleQueryFuncInvoked = true diff --git a/server/service/certificate_templates_test.go b/server/service/certificate_templates_test.go index ca53b1b59b..b8086230ad 100644 --- a/server/service/certificate_templates_test.go +++ b/server/service/certificate_templates_test.go @@ -6,6 +6,7 @@ import ( "strings" "testing" + activity_api "github.com/fleetdm/fleet/v4/server/activity/api" "github.com/fleetdm/fleet/v4/server/contexts/viewer" "github.com/fleetdm/fleet/v4/server/fleet" "github.com/fleetdm/fleet/v4/server/mock" @@ -379,3 +380,79 @@ func TestApplyCertificateTemplateSpecs(t *testing.T) { require.Contains(t, err.Error(), "Template 2") }) } + +func TestResendHostCertificateTemplate(t *testing.T) { + ds := new(mock.Store) + opts := &TestServerOpts{} + svc, ctx := newTestService(t, ds, nil, nil, opts) + + const ( + hostID = uint(1) + templateID = uint(42) + teamID = uint(10) + templateName = "My Cert" + ) + + ctx = viewer.NewContext(ctx, viewer.Viewer{User: &fleet.User{GlobalRole: ptr.String(fleet.RoleAdmin)}}) + + ds.HostLiteFunc = func(ctx context.Context, id uint) (*fleet.Host, error) { + if id == hostID { + tid := teamID + return &fleet.Host{ID: id, TeamID: &tid}, nil + } + return nil, errors.New("host not found") + } + + ds.GetCertificateTemplateByIdFunc = func(ctx context.Context, id uint) (*fleet.CertificateTemplateResponse, error) { + return &fleet.CertificateTemplateResponse{ + CertificateTemplateResponseSummary: fleet.CertificateTemplateResponseSummary{ + ID: id, + Name: templateName, + }, + }, nil + } + + t.Run("succeeds and creates activity", func(t *testing.T) { + ds.ResendHostCertificateTemplateFunc = func(ctx context.Context, hID uint, tID uint) error { + require.Equal(t, hostID, hID) + require.Equal(t, templateID, tID) + return nil + } + + var capturedActivity fleet.ActivityTypeResentCertificate + opts.ActivityMock.NewActivityFunc = func(_ context.Context, _ *activity_api.User, activity activity_api.ActivityDetails) error { + act, ok := activity.(fleet.ActivityTypeResentCertificate) + require.True(t, ok, "expected ActivityTypeResentCertificate, got %T", activity) + capturedActivity = act + return nil + } + + err := svc.ResendHostCertificateTemplate(ctx, hostID, templateID) + require.NoError(t, err) + require.True(t, ds.ResendHostCertificateTemplateFuncInvoked) + require.True(t, opts.ActivityMock.NewActivityFuncInvoked) + require.Equal(t, hostID, capturedActivity.HostID) + require.Equal(t, templateID, capturedActivity.CertificateTemplateID) + require.Equal(t, templateName, capturedActivity.CertificateName) + + ds.ResendHostCertificateTemplateFuncInvoked = false + opts.ActivityMock.NewActivityFuncInvoked = false + }) + + t.Run("returns error when host not found", func(t *testing.T) { + err := svc.ResendHostCertificateTemplate(ctx, 99999, templateID) + require.Error(t, err) + require.False(t, opts.ActivityMock.NewActivityFuncInvoked) + }) + + t.Run("returns error when datastore fails", func(t *testing.T) { + ds.ResendHostCertificateTemplateFunc = func(ctx context.Context, hID uint, tID uint) error { + return errors.New("db error") + } + + err := svc.ResendHostCertificateTemplate(ctx, hostID, templateID) + require.Error(t, err) + require.Contains(t, err.Error(), "db error") + require.False(t, opts.ActivityMock.NewActivityFuncInvoked) + }) +} diff --git a/server/service/certificates.go b/server/service/certificates.go index d44e8d3bd6..a88aae7ce6 100644 --- a/server/service/certificates.go +++ b/server/service/certificates.go @@ -697,3 +697,60 @@ func (svc *Service) UpdateCertificateStatus(ctx context.Context, update *fleet.C update.HostUUID = host.UUID return svc.ds.UpsertCertificateStatus(ctx, update) } + +//////////////////////////////////////////////////////////////////////////////// +// Resend Host Certificate Template +//////////////////////////////////////////////////////////////////////////////// + +type resendHostCertificateTemplateRequest struct { + ID uint `url:"id"` + TemplateID uint `url:"template_id"` +} + +type resendHostCertificateTemplateResponse struct { + Err error `json:"error,omitempty"` +} + +func (r resendHostCertificateTemplateResponse) Error() error { return r.Err } + +func resendHostCertificateTemplateEndpoint(ctx context.Context, request any, svc fleet.Service) (fleet.Errorer, error) { + req := request.(*resendHostCertificateTemplateRequest) + err := svc.ResendHostCertificateTemplate(ctx, req.ID, req.TemplateID) + if err != nil { + return resendHostCertificateTemplateResponse{Err: err}, nil + } + + return resendHostCertificateTemplateResponse{}, nil +} + +func (svc *Service) ResendHostCertificateTemplate(ctx context.Context, hostID uint, templateID uint) error { + host, err := svc.ds.HostLite(ctx, hostID) + if err != nil { + svc.authz.SkipAuthorization(ctx) + return ctxerr.Wrap(ctx, err) + } + + if err := svc.authz.Authorize(ctx, &fleet.MDMConfigProfileAuthz{TeamID: host.TeamID}, fleet.ActionResend); err != nil { + return ctxerr.Wrap(ctx, err) + } + + if err := svc.ds.ResendHostCertificateTemplate(ctx, hostID, templateID); err != nil { + return ctxerr.Wrap(ctx, err, "resending certificate template") + } + + certificate, err := svc.ds.GetCertificateTemplateById(ctx, templateID) + if err != nil { + return ctxerr.Wrap(ctx, err, "getting certificate details") + } + + if err := svc.NewActivity(ctx, authz.UserFromContext(ctx), fleet.ActivityTypeResentCertificate{ + HostID: host.ID, + HostDisplayName: host.DisplayName(), + CertificateTemplateID: certificate.ID, + CertificateName: certificate.Name, + }); err != nil { + return ctxerr.Wrap(ctx, err, "creating activity") + } + + return nil +} diff --git a/server/service/handler.go b/server/service/handler.go index 9d9ad3cc22..fe0eac7e40 100644 --- a/server/service/handler.go +++ b/server/service/handler.go @@ -478,6 +478,7 @@ func attachFleetAPIRoutes(r *mux.Router, svc fleet.Service, config config.FleetC ue.DELETE("/api/_version_/fleet/hosts/{id:[0-9]+}/labels", removeLabelsFromHostEndpoint, removeLabelsFromHostRequest{}) ue.GET("/api/_version_/fleet/hosts/{id:[0-9]+}/software", getHostSoftwareEndpoint, getHostSoftwareRequest{}) ue.GET("/api/_version_/fleet/hosts/{id:[0-9]+}/certificates", listHostCertificatesEndpoint, listHostCertificatesRequest{}) + ue.POST("/api/_version_/fleet/hosts/{id:[0-9]+}/certificates/{template_id:[0-9]+}/resend", resendHostCertificateTemplateEndpoint, resendHostCertificateTemplateRequest{}) ue.GET("/api/_version_/fleet/hosts/{id:[0-9]+}/recovery_lock_password", getHostRecoveryLockPasswordEndpoint, getHostRecoveryLockPasswordRequest{}) ue.GET("/api/_version_/fleet/hosts/summary/mdm", getHostMDMSummary, getHostMDMSummaryRequest{}) diff --git a/server/service/hosts.go b/server/service/hosts.go index 358d62e0db..8ee0574065 100644 --- a/server/service/hosts.go +++ b/server/service/hosts.go @@ -3815,9 +3815,9 @@ func (svc *Service) ListHostCertificates(ctx context.Context, hostID uint, opts return payload, meta, nil } -// ////////////////////////////////////////////////////////////////////////////// +//////////////////////////////////////////////////////////////////////////////// // Get Host Recovery Lock Password -// ////////////////////////////////////////////////////////////////////////////// +//////////////////////////////////////////////////////////////////////////////// type getHostRecoveryLockPasswordRequest struct { ID uint `url:"id"` diff --git a/server/service/integration_android_certificate_templates_test.go b/server/service/integration_android_certificate_templates_test.go index 1b821d50a7..a6624077ae 100644 --- a/server/service/integration_android_certificate_templates_test.go +++ b/server/service/integration_android_certificate_templates_test.go @@ -1363,3 +1363,112 @@ func (s *integrationMDMTestSuite) TestCertificateTemplateAuthorizationForTeamUse s.DoJSON("GET", fmt.Sprintf("/api/latest/fleet/certificates?team_id=%d", otherTeamID), nil, http.StatusForbidden, &listCertificateTemplatesResponse{}) }) } + +// TestCertificateTemplateResend tests the resend endpoint for Android certificate templates: +// 1. After a certificate reaches 'verified' status, calling resend resets it to 'pending' +// 2. The UUID changes after resend (signals the device to re-fetch) +// 3. The reconcile cron picks up the pending template and re-delivers it +func (s *integrationMDMTestSuite) TestCertificateTemplateResend() { + t := s.T() + ctx := t.Context() + enterpriseID := s.enableAndroidMDM(t) + + // Create a test team + teamName := t.Name() + "-team" + var createTeamResp teamResponse + s.DoJSON("POST", "/api/latest/fleet/teams", createTeamRequest{ + TeamPayload: fleet.TeamPayload{ + Name: ptr.String(teamName), + }, + }, http.StatusOK, &createTeamResp) + teamID := createTeamResp.Team.ID + + // Create a test certificate authority + caID, _ := s.createTestCertificateAuthority(t, ctx) + + // Create an enrolled Android host in the team + host, orbitNodeKey := s.createEnrolledAndroidHost(t, ctx, enterpriseID, &teamID, "1") + + // Create a certificate template for the team + certTemplateName := strings.ReplaceAll(t.Name(), "/", "-") + "-CertTemplate" + var createResp createCertificateTemplateResponse + s.DoJSON("POST", "/api/latest/fleet/certificates", createCertificateTemplateRequest{ + Name: certTemplateName, + TeamID: teamID, + CertificateAuthorityId: caID, + SubjectName: "CN=$FLEET_VAR_HOST_HARDWARE_SERIAL", + }, http.StatusOK, &createResp) + require.NotZero(t, createResp.ID) + certTemplateID := createResp.ID + + // Verify initial pending status + s.verifyCertificateStatus(t, host, orbitNodeKey, certTemplateID, certTemplateName, caID, + fleet.CertificateTemplatePending, "") + + // Trigger reconciliation to deliver the certificate template + s.awaitTriggerAndroidProfileSchedule(t) + + // Verify status is now 'delivered' + s.verifyCertificateStatus(t, host, orbitNodeKey, certTemplateID, certTemplateName, caID, + fleet.CertificateTemplateDelivered, "") + + // Simulate device reporting certificate enrollment as verified + updateReq, err := json.Marshal(updateCertificateStatusRequest{ + Status: string(fleet.CertificateTemplateVerified), + }) + require.NoError(t, err) + resp := s.DoRawWithHeaders("PUT", fmt.Sprintf("/api/fleetd/certificates/%d/status", certTemplateID), updateReq, http.StatusOK, map[string]string{ + "Authorization": fmt.Sprintf("Node key %s", orbitNodeKey), + }) + _ = resp.Body.Close() + + // Verify status is 'verified' + s.verifyCertificateStatus(t, host, orbitNodeKey, certTemplateID, certTemplateName, caID, + fleet.CertificateTemplateVerified, "") + + // Record UUID before resend + originalRecord, err := s.ds.GetHostCertificateTemplateRecord(ctx, host.UUID, certTemplateID) + require.NoError(t, err) + originalUUID := originalRecord.UUID + + // Call the resend endpoint + s.DoJSON("POST", fmt.Sprintf("/api/latest/fleet/hosts/%d/certificates/%d/resend", host.ID, certTemplateID), + nil, http.StatusOK, &struct{}{}) + + // Verify activity was created for the resend + s.lastActivityOfTypeMatches( + fleet.ActivityTypeResentCertificate{}.ActivityName(), + fmt.Sprintf( + `{"host_id": %d, "host_display_name": %q, "certificate_template_id": %d, "certificate_name": %q}`, + host.ID, + host.DisplayName(), + certTemplateID, + certTemplateName, + ), + 0) + + // Verify status is reset to 'pending' and UUID changed + updatedRecord, err := s.ds.GetHostCertificateTemplateRecord(ctx, host.UUID, certTemplateID) + require.NoError(t, err) + require.Equal(t, fleet.CertificateTemplatePending, updatedRecord.Status) + require.NotEqual(t, originalUUID, updatedRecord.UUID, "UUID should change after resend") + + // Verify the host API reflects pending status + s.verifyCertificateStatus(t, host, orbitNodeKey, certTemplateID, certTemplateName, caID, + fleet.CertificateTemplatePending, "") + + // Trigger reconciliation again - should re-deliver with new UUID + s.awaitTriggerAndroidProfileSchedule(t) + + // Verify status is 'delivered' again after reconciliation + s.verifyCertificateStatus(t, host, orbitNodeKey, certTemplateID, certTemplateName, caID, + fleet.CertificateTemplateDelivered, "") + + // Resend for a non-existent host should return 404 + s.DoJSON("POST", fmt.Sprintf("/api/latest/fleet/hosts/%d/certificates/%d/resend", 99999, certTemplateID), + nil, http.StatusNotFound, &struct{}{}) + + // Resend for a non-existent template returns 404 + s.DoJSON("POST", fmt.Sprintf("/api/latest/fleet/hosts/%d/certificates/%d/resend", host.ID, 99999), + nil, http.StatusNotFound, &struct{}{}) +}