41542 android cert resend backend (#42099)

<!-- Add the related story/sub-task/bug number, like Resolves #123, or
remove if NA -->
**Related issue:** Resolves #41542
This commit is contained in:
Dante Catalfamo 2026-03-23 17:01:52 -04:00 committed by GitHub
parent d55d2571bb
commit 4df9ae01a6
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
13 changed files with 426 additions and 2 deletions

View file

@ -0,0 +1 @@
- Add backend to resend Android certificates

View file

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

View file

@ -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)
})
}

View file

@ -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"`

View file

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

View file

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

View file

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

View file

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

View file

@ -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)
})
}

View file

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

View file

@ -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{})

View file

@ -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"`

View file

@ -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{}{})
}