Files
fleet/server/service/certificate_templates_test.go
T
Victor Lyuboslavsky c79d33a3a6 Add support for SAN in Android certificate templates. (#44690)
2/3rds of this PR is OpenSpec and tests. Use OpenSpec files as a
reference (if needed). They're there to help the review, and not to be a
review surface themselves.

- Backend implementation for `subject_alternative_name` in certificate
templates.
- Includes schema migration, variable expansion, GitOps support.
- Limits SAN types to `DNS`, `EMAIL`, `UPN`, `IP`, and `URI`.

<!-- Add the related story/sub-task/bug number, like Resolves #123, or
remove if NA -->
**Related issue:** Resolves #41472 

# Checklist for submitter

If some of the following don't apply, delete the relevant line.

- [x] Changes file added for user-visible changes in `changes/`,
`orbit/changes/` or `ee/fleetd-chrome/changes`.
See [Changes
files](https://github.com/fleetdm/fleet/blob/main/docs/Contributing/guides/committing-changes.md#changes-files)
for more information.

- [x] Input data is properly validated, `SELECT *` is avoided, SQL
injection is prevented (using placeholders for values in statements), JS
inline code is prevented especially for url redirects, and untrusted
data interpolated into shell scripts/commands is validated against shell
metacharacters.

## Testing

- [x] Added/updated automated tests
- [x] QA'd all new/changed functionality manually

## Database migrations

- [x] Checked schema for all modified table for columns that will
auto-update timestamps during migration.

## New Fleet configuration settings

- [x] Verified that the setting is exported via `fleetctl
generate-gitops`
- [x] Verified the setting is documented in a separate PR to [the GitOps
documentation](https://github.com/fleetdm/fleet/blob/main/docs/Configuration/yaml-files.md#L485)
- [x] Verified that the setting is cleared on the server if it is not
supplied in a YAML file (or that it is documented as being optional)
- [ ] Verified that any relevant UI is disabled when GitOps mode is
enabled


<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->
## Summary by CodeRabbit

* **New Features**
* Android certificate templates support Subject Alternative Name (SAN)
with validation (DNS, EMAIL, UPN, IP, URI), Fleet-variable substitution,
runtime expansion, and delivery; SAN use is gated by Premium license
  * GitOps now validates and includes SAN in Android certificate flows

* **Chores**
  * Database schema updated to store SAN on certificate templates
  * Changelog entry added

* **Tests**
* Added unit and end-to-end tests covering SAN validation, variable
expansion, and GitOps behavior
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2026-05-06 14:43:09 -05:00

669 lines
24 KiB
Go

package service
import (
"context"
"errors"
"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"
"github.com/fleetdm/fleet/v4/server/ptr"
"github.com/stretchr/testify/require"
)
func TestCreateCertificateTemplate(t *testing.T) {
ds := new(mock.Store)
// Certificate templates are Premium-gated (CAs are Premium, and templates require a CA).
svc, ctx := newTestService(t, ds, nil, nil, &TestServerOpts{License: &fleet.LicenseInfo{Tier: fleet.TierPremium}})
ctx = viewer.NewContext(ctx, viewer.Viewer{User: &fleet.User{GlobalRole: ptr.String(fleet.RoleAdmin)}})
type TestCAID uint
const (
InvalidCATypeID TestCAID = iota + 1
ValidCATypeID
)
const TeamID = 1
ds.GetCertificateAuthorityByIDFunc = func(ctx context.Context, id uint, includeSecrets bool) (*fleet.CertificateAuthority, error) {
if id == uint(InvalidCATypeID) {
ca := fleet.CertificateAuthority{
ID: id,
Type: string(fleet.CATypeDigiCert),
}
return &ca, nil
}
if id == uint(ValidCATypeID) {
ca := fleet.CertificateAuthority{
ID: id,
Type: string(fleet.CATypeCustomSCEPProxy),
}
return &ca, nil
}
return nil, errors.New("not found")
}
ds.CreateCertificateTemplateFunc = func(ctx context.Context, certificateTemplate *fleet.CertificateTemplate) (*fleet.CertificateTemplateResponse, error) {
return &fleet.CertificateTemplateResponse{
CertificateTemplateResponseSummary: fleet.CertificateTemplateResponseSummary{
ID: 1,
Name: certificateTemplate.Name,
},
}, nil
}
ds.CreatePendingCertificateTemplatesForExistingHostsFunc = func(ctx context.Context, certificateTemplateID uint, teamID uint) (int64, error) {
return 0, nil
}
ds.TeamLiteFunc = func(ctx context.Context, tid uint) (*fleet.TeamLite, error) {
return &fleet.TeamLite{ID: tid, Name: "Yellow jackets"}, nil
}
ds.AppConfigFunc = func(ctx context.Context) (*fleet.AppConfig, error) {
return &fleet.AppConfig{}, nil
}
t.Run("Invalid CA type", func(t *testing.T) {
_, err := svc.CreateCertificateTemplate(ctx, "my template", TeamID, uint(InvalidCATypeID), "CN=$FLEET_VAR_HOST_UUID", "")
require.Error(t, err)
// Check that the error is about invalid CA type
require.Contains(t, err.Error(), "Currently, only the custom_scep_proxy certificate authority is supported")
})
t.Run("Valid CA type", func(t *testing.T) {
_, err := svc.CreateCertificateTemplate(ctx, "my template", TeamID, uint(ValidCATypeID), "CN=$FLEET_VAR_HOST_UUID", "")
require.NoError(t, err)
})
t.Run("Missing CA", func(t *testing.T) {
_, err := svc.CreateCertificateTemplate(ctx, "my template", TeamID, 999, "CN=$FLEET_VAR_HOST_UUID", "")
require.Error(t, err)
// Check that the error is about invalid CA type
require.Contains(t, err.Error(), "not found")
})
t.Run("Empty or whitespace-only name", func(t *testing.T) {
whitespaceNames := []string{"", " ", " ", "\t", "\n", " \t\n "}
for _, name := range whitespaceNames {
_, err := svc.CreateCertificateTemplate(ctx, name, TeamID, uint(ValidCATypeID), "CN=$FLEET_VAR_HOST_UUID", "")
require.Error(t, err)
require.Contains(t, err.Error(), "Certificate template name is required")
}
})
t.Run("Name too long", func(t *testing.T) {
longName := strings.Repeat("a", 256)
_, err := svc.CreateCertificateTemplate(ctx, longName, TeamID, uint(ValidCATypeID), "CN=$FLEET_VAR_HOST_UUID", "")
require.Error(t, err)
require.Contains(t, err.Error(), "Certificate template name is too long")
})
t.Run("Name with invalid characters", func(t *testing.T) {
testCases := []struct {
name string
}{
{name: "template@name"},
{name: "template#name"},
{name: "template$name"},
{name: "template%name"},
{name: "template.name"},
{name: "template/name"},
{name: "template\\name"},
{name: "template!name"},
{name: "template?name"},
{name: "template*name"},
{name: "template+name"},
{name: "template=name"},
{name: "template<name>"},
{name: "template(name)"},
{name: "template[name]"},
{name: "template{name}"},
{name: "template|name"},
{name: "template;name"},
{name: "template:name"},
{name: "template'name"},
{name: "template\"name"},
{name: "template`name"},
{name: "template~name"},
{name: "template^name"},
{name: "template name"},
}
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
_, err := svc.CreateCertificateTemplate(ctx, tc.name, TeamID, uint(ValidCATypeID), "CN=$FLEET_VAR_HOST_UUID", "")
require.Error(t, err)
require.Contains(t, err.Error(), "Invalid certificate template name")
})
}
})
t.Run("Name with valid characters", func(t *testing.T) {
validNames := []string{
"my template",
" my template ",
"my-template",
"my_template",
"MyTemplate123",
"Template 1",
"UPPERCASE",
"lowercase",
"Mix-Ed_Case 123",
"a",
"1",
"a1",
"1a",
}
for _, name := range validNames {
t.Run(name, func(t *testing.T) {
_, err := svc.CreateCertificateTemplate(ctx, name, TeamID, uint(ValidCATypeID), "CN=$FLEET_VAR_HOST_UUID", "")
require.NoError(t, err)
})
}
})
t.Run("Empty or whitespace-only subject name", func(t *testing.T) {
whitespaceSubjectNames := []string{"", " ", " \t\n "}
for _, subjectName := range whitespaceSubjectNames {
_, err := svc.CreateCertificateTemplate(ctx, "my template", TeamID, uint(ValidCATypeID), subjectName, "")
require.Error(t, err)
require.Contains(t, err.Error(), "Certificate template subject name is required")
}
})
}
func TestCreateCertificateTemplateSubjectAlternativeName(t *testing.T) {
const ValidCATypeID = uint(2)
const TeamID = 1
makePremiumService := func(t *testing.T) (fleet.Service, context.Context, *mock.Store) {
ds := new(mock.Store)
svc, ctx := newTestService(t, ds, nil, nil, &TestServerOpts{License: &fleet.LicenseInfo{Tier: fleet.TierPremium}})
ctx = viewer.NewContext(ctx, viewer.Viewer{User: &fleet.User{GlobalRole: ptr.String(fleet.RoleAdmin)}})
ds.GetCertificateAuthorityByIDFunc = func(ctx context.Context, id uint, includeSecrets bool) (*fleet.CertificateAuthority, error) {
return &fleet.CertificateAuthority{ID: id, Type: string(fleet.CATypeCustomSCEPProxy)}, nil
}
ds.CreateCertificateTemplateFunc = func(ctx context.Context, certificateTemplate *fleet.CertificateTemplate) (*fleet.CertificateTemplateResponse, error) {
return &fleet.CertificateTemplateResponse{
CertificateTemplateResponseSummary: fleet.CertificateTemplateResponseSummary{
ID: 1,
Name: certificateTemplate.Name,
SubjectName: certificateTemplate.SubjectName,
SubjectAlternativeName: certificateTemplate.SubjectAlternativeName,
},
TeamID: certificateTemplate.TeamID,
}, nil
}
ds.CreatePendingCertificateTemplatesForExistingHostsFunc = func(ctx context.Context, certificateTemplateID uint, teamID uint) (int64, error) {
return 0, nil
}
ds.TeamLiteFunc = func(ctx context.Context, tid uint) (*fleet.TeamLite, error) {
return &fleet.TeamLite{ID: tid, Name: "Yellow jackets"}, nil
}
ds.AppConfigFunc = func(ctx context.Context) (*fleet.AppConfig, error) {
return &fleet.AppConfig{}, nil
}
return svc, ctx, ds
}
t.Run("Premium tenant with valid SAN succeeds and round-trips the value", func(t *testing.T) {
svc, ctx, ds := makePremiumService(t)
san := "DNS=wifi.example.com, UPN=$FLEET_VAR_HOST_END_USER_IDP_USERNAME, EMAIL=$FLEET_VAR_HOST_END_USER_IDP_USERNAME"
resp, err := svc.CreateCertificateTemplate(ctx, "wifi", TeamID, ValidCATypeID, "CN=$FLEET_VAR_HOST_UUID", san)
require.NoError(t, err)
require.Equal(t, san, resp.SubjectAlternativeName)
require.True(t, ds.CreateCertificateTemplateFuncInvoked)
})
t.Run("Non-Premium tenant cannot create any certificate template (gate is in CreateCertificateTemplate, before validation)", func(t *testing.T) {
// Certificate templates require a CA, and CAs are Premium-only, so the whole feature is
// gated by a Premium check at the top of Service.CreateCertificateTemplate. SAN-bearing
// payloads are not the only ones rejected.
ds := new(mock.Store)
svc, ctx := newTestService(t, ds, nil, nil, &TestServerOpts{License: &fleet.LicenseInfo{Tier: fleet.TierFree}})
ctx = viewer.NewContext(ctx, viewer.Viewer{User: &fleet.User{GlobalRole: ptr.String(fleet.RoleAdmin)}})
// With SAN.
_, err := svc.CreateCertificateTemplate(ctx, "wifi-with-san", TeamID, ValidCATypeID, "CN=$FLEET_VAR_HOST_UUID", "DNS=example.com")
require.ErrorIs(t, err, fleet.ErrMissingLicense)
// Without SAN also rejected.
_, err = svc.CreateCertificateTemplate(ctx, "wifi-no-san", TeamID, ValidCATypeID, "CN=$FLEET_VAR_HOST_UUID", "")
require.ErrorIs(t, err, fleet.ErrMissingLicense)
})
t.Run("Format failures return InvalidArgumentError scoped to the SAN field", func(t *testing.T) {
svc, ctx, _ := makePremiumService(t)
cases := []struct {
name string
san string
fragment string
}{
{"missing equals", "DNS=ok, OOPS", "missing '='"},
{"unknown key", "FOO=bar", "unsupported key"},
{"rfc822 not synonym", "RFC822=user@x", "unsupported key"},
{"too long", strings.Repeat("DNS=a,", 1024), "too long"},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
_, err := svc.CreateCertificateTemplate(ctx, "wifi", TeamID, ValidCATypeID, "CN=$FLEET_VAR_HOST_UUID", tc.san)
require.Error(t, err)
var iae *fleet.InvalidArgumentError
require.ErrorAs(t, err, &iae)
require.True(t, iae.HasErrors())
details := iae.Invalid()
require.Len(t, details, 1)
require.Equal(t, "subject_alternative_name", details[0]["name"])
require.Contains(t, details[0]["reason"], tc.fragment)
})
}
})
t.Run("Unsupported variable in SAN is rejected", func(t *testing.T) {
svc, ctx, _ := makePremiumService(t)
_, err := svc.CreateCertificateTemplate(ctx, "wifi", TeamID, ValidCATypeID, "CN=$FLEET_VAR_HOST_UUID", "EMAIL=$FLEET_VAR_HOST_PLATFORM")
require.Error(t, err)
require.Contains(t, err.Error(), "FLEET_VAR_HOST_PLATFORM")
})
}
func TestValidateCertificateTemplateSubjectAlternativeName(t *testing.T) {
cases := []struct {
name string
san string
expectError bool
errContains string
}{
{"empty allowed", "", false, ""},
{"whitespace allowed", " \t\n ", false, ""},
{"single DNS", "DNS=example.com", false, ""},
{"single EMAIL", "EMAIL=user@example.com", false, ""},
{"single UPN", "UPN=user@corp.example.com", false, ""},
{"single IP", "IP=10.0.0.1", false, ""},
{"single URI", "URI=spiffe://example.com/x", false, ""},
{"all five mixed", "DNS=a, EMAIL=b@x, UPN=c@d, IP=10.0.0.1, URI=spiffe://x", false, ""},
{"case insensitive keys", "dns=a, email=b@x, upn=c@d, ip=10.0.0.1, uri=spiffe://x", false, ""},
{"repeated keys", "DNS=a, DNS=b, EMAIL=c@x, EMAIL=d@y", false, ""},
{"trailing comma is fine", "DNS=a,", false, ""},
{"missing equals", "DNS=a, OOPS", true, "missing '='"},
{"unknown key FOO", "FOO=bar", true, "unsupported key"},
{"RFC822 is not a synonym", "RFC822=user@x", true, "unsupported key"},
{"length cap", strings.Repeat("DNS=a,", 1024), true, "too long"},
{"empty key with equals only", "=value", true, "empty key"},
{"empty value DNS=", "DNS=", true, "empty value"},
{"empty value EMAIL= mixed", "DNS=ok.example.com, EMAIL=", true, "empty value"},
{"separator only", ",", true, "no entries"},
{"separator only with whitespace", " , ", true, "no entries"},
{"only commas", ",,,", true, "no entries"},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
err := validateCertificateTemplateSubjectAlternativeName(tc.san, "")
if tc.expectError {
require.Error(t, err)
require.Contains(t, err.Error(), tc.errContains)
// Validator must return a typed *fleet.InvalidArgumentError scoped to the SAN field (HTTP 422).
var iae *fleet.InvalidArgumentError
require.ErrorAs(t, err, &iae)
require.True(t, iae.HasErrors())
details := iae.Invalid()
require.Len(t, details, 1)
require.Equal(t, "subject_alternative_name", details[0]["name"])
} else {
require.NoError(t, err)
}
})
}
}
func TestApplyCertificateTemplateSpecs(t *testing.T) {
ds := new(mock.Store)
// Certificate templates are Premium-gated.
svc, ctx := newTestService(t, ds, nil, nil, &TestServerOpts{License: &fleet.LicenseInfo{Tier: fleet.TierPremium}})
ctx = viewer.NewContext(ctx, viewer.Viewer{User: &fleet.User{GlobalRole: ptr.String(fleet.RoleAdmin)}})
ds.AppConfigFunc = func(ctx context.Context) (*fleet.AppConfig, error) {
return &fleet.AppConfig{}, nil
}
ds.TeamLiteFunc = func(ctx context.Context, id uint) (*fleet.TeamLite, error) {
return &fleet.TeamLite{
ID: id,
Name: "Test Team",
}, nil
}
// Set up certificate authority mocks
certAuthorities := []*fleet.CertificateAuthority{
{
ID: 1,
Name: ptr.String("Test CA 1"),
Type: string(fleet.CATypeCustomSCEPProxy),
URL: ptr.String("https://ca1.example.com"),
Challenge: ptr.String("challenge1"),
},
{
ID: 2,
Name: ptr.String("Test CA 2"),
Type: string(fleet.CATypeCustomSCEPProxy),
URL: ptr.String("https://ca2.example.com"),
Challenge: ptr.String("challenge2"),
},
{
ID: 3,
Name: ptr.String("Test CA 3"),
Type: string(fleet.CATypeDigiCert),
URL: ptr.String("https://ca3.example.com"),
Challenge: ptr.String("challenge3"),
CertificateCommonName: ptr.String("foo"),
CertificateSeatID: ptr.String("foo"),
CertificateUserPrincipalNames: &[]string{"foo"},
APIToken: ptr.String("foo"),
ProfileID: ptr.String("foo"),
},
}
ds.ListCertificateAuthoritiesFunc = func(ctx context.Context) ([]*fleet.CertificateAuthoritySummary, error) {
summaries := make([]*fleet.CertificateAuthoritySummary, 0, len(certAuthorities))
for _, ca := range certAuthorities {
summaries = append(summaries, &fleet.CertificateAuthoritySummary{
ID: ca.ID,
Name: *ca.Name,
Type: ca.Type,
})
}
return summaries, nil
}
// Track certificate templates that are created
var createdCertificates []fleet.CertificateTemplate
var nextTemplateID uint = 100
ds.BatchUpsertCertificateTemplatesFunc = func(ctx context.Context, certificates []*fleet.CertificateTemplate) ([]uint, error) {
createdCertificates = nil
createdMap := make([]uint, 0, len(certificates))
for _, cert := range certificates {
createdCertificates = append(createdCertificates, *cert)
createdMap = append(createdMap, cert.TeamID)
}
return createdMap, nil
}
ds.GetCertificateTemplatesByTeamIDFunc = func(ctx context.Context, teamID uint, opts fleet.ListOptions) ([]*fleet.CertificateTemplateResponseSummary, *fleet.PaginationMetadata, error) {
var result []*fleet.CertificateTemplateResponseSummary
for _, cert := range createdCertificates {
if cert.TeamID == teamID {
result = append(result, &fleet.CertificateTemplateResponseSummary{
ID: nextTemplateID,
Name: cert.Name,
})
nextTemplateID++
}
}
return result, nil, nil
}
ds.CreatePendingCertificateTemplatesForExistingHostsFunc = func(ctx context.Context, certificateTemplateID uint, teamID uint) (int64, error) {
return 0, nil
}
ds.GetCertificateTemplateByTeamIDAndNameFunc = func(ctx context.Context, teamID uint, name string) (*fleet.CertificateTemplateResponse, error) {
return &fleet.CertificateTemplateResponse{
CertificateTemplateResponseSummary: fleet.CertificateTemplateResponseSummary{
ID: nextTemplateID,
Name: name,
},
TeamID: teamID,
}, nil
}
t.Run("Valid CA types", func(t *testing.T) {
err := svc.ApplyCertificateTemplateSpecs(ctx, []*fleet.CertificateRequestSpec{
{
Name: "Template 1",
CertificateAuthorityId: 1,
SubjectName: "foo",
},
{
Name: "Template 2",
CertificateAuthorityId: 2,
SubjectName: "bar",
},
})
require.NoError(t, err)
require.Len(t, createdCertificates, 2)
require.Equal(t, "Template 1", createdCertificates[0].Name)
require.Equal(t, uint(1), createdCertificates[0].CertificateAuthorityID)
require.Equal(t, "Template 2", createdCertificates[1].Name)
require.Equal(t, uint(2), createdCertificates[1].CertificateAuthorityID)
})
t.Run("Invalid CA type", func(t *testing.T) {
err := svc.ApplyCertificateTemplateSpecs(ctx, []*fleet.CertificateRequestSpec{
{
Name: "Template 3",
CertificateAuthorityId: 3,
SubjectName: "baz",
},
})
require.Error(t, err)
require.Contains(t, err.Error(), "Currently, only the custom_scep_proxy certificate authority is supported")
})
t.Run("Missing CA", func(t *testing.T) {
err := svc.ApplyCertificateTemplateSpecs(ctx, []*fleet.CertificateRequestSpec{
{
Name: "Template 4",
CertificateAuthorityId: 4,
SubjectName: "baz",
},
})
require.Error(t, err)
require.Contains(t, err.Error(), "not found")
})
t.Run("Empty name", func(t *testing.T) {
err := svc.ApplyCertificateTemplateSpecs(ctx, []*fleet.CertificateRequestSpec{
{
Name: "",
CertificateAuthorityId: 1,
SubjectName: "foo",
},
})
require.Error(t, err)
require.Contains(t, err.Error(), "Certificate template name is required")
})
t.Run("Whitespace-only name", func(t *testing.T) {
err := svc.ApplyCertificateTemplateSpecs(ctx, []*fleet.CertificateRequestSpec{
{
Name: " ",
CertificateAuthorityId: 1,
SubjectName: "foo",
},
})
require.Error(t, err)
require.Contains(t, err.Error(), "Certificate template name is required")
})
t.Run("Name with invalid characters", func(t *testing.T) {
err := svc.ApplyCertificateTemplateSpecs(ctx, []*fleet.CertificateRequestSpec{
{
Name: "template@name",
CertificateAuthorityId: 1,
SubjectName: "foo",
},
})
require.Error(t, err)
require.Contains(t, err.Error(), "Invalid certificate template name")
})
t.Run("Name too long", func(t *testing.T) {
longName := strings.Repeat("a", 256)
err := svc.ApplyCertificateTemplateSpecs(ctx, []*fleet.CertificateRequestSpec{
{
Name: longName,
CertificateAuthorityId: 1,
SubjectName: "foo",
},
})
require.Error(t, err)
require.Contains(t, err.Error(), "Certificate template name is too long")
})
t.Run("Whitespace-only subject name", func(t *testing.T) {
err := svc.ApplyCertificateTemplateSpecs(ctx, []*fleet.CertificateRequestSpec{
{
Name: "Template 2",
CertificateAuthorityId: 1,
SubjectName: " ",
},
})
require.Error(t, err)
require.Contains(t, err.Error(), "Certificate template subject name is required")
require.Contains(t, err.Error(), "Template 2")
})
// SAN coverage: the only Apply-specific assertion worth a unit test is that the cert-name
// suffix from validateCertificateTemplateSubjectAlternativeName reaches the typed error so
// admins applying a multi-cert spec can identify which entry is bad. Other SAN paths
// (validator semantics, single-cert Create) are covered in TestValidateCertificateTemplate*
// and TestCreateCertificateTemplate*.
t.Run("Invalid SAN rejected with cert-name suffix in subject_alternative_name field", func(t *testing.T) {
err := svc.ApplyCertificateTemplateSpecs(ctx, []*fleet.CertificateRequestSpec{
{
Name: "Template SAN bad",
CertificateAuthorityId: 1,
SubjectName: "CN=$FLEET_VAR_HOST_UUID",
SubjectAlternativeName: "FOO=bar",
},
})
require.Error(t, err)
var iae *fleet.InvalidArgumentError
require.ErrorAs(t, err, &iae)
details := iae.Invalid()
require.Len(t, details, 1)
require.Equal(t, "subject_alternative_name", details[0]["name"])
require.Contains(t, details[0]["reason"], "Template SAN bad")
require.Contains(t, details[0]["reason"], `unsupported key "FOO"`)
})
}
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
}
ds.GetCertificateTemplateByIdForHostFunc = func(ctx context.Context, id uint, hostUUID string) (*fleet.CertificateTemplateResponseForHost, error) {
return &fleet.CertificateTemplateResponseForHost{
CertificateTemplateResponse: fleet.CertificateTemplateResponse{
CertificateTemplateResponseSummary: fleet.CertificateTemplateResponseSummary{
ID: id,
Name: templateName,
},
},
Status: fleet.CertificateTemplateDelivered,
}, 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)
})
t.Run("returns 400 when template is pending for host", func(t *testing.T) {
ds.GetCertificateTemplateByIdForHostFunc = func(ctx context.Context, id uint, hostUUID string) (*fleet.CertificateTemplateResponseForHost, error) {
return &fleet.CertificateTemplateResponseForHost{
CertificateTemplateResponse: fleet.CertificateTemplateResponse{
CertificateTemplateResponseSummary: fleet.CertificateTemplateResponseSummary{
ID: id,
Name: templateName,
},
},
Status: fleet.CertificateTemplatePending,
}, nil
}
ds.ResendHostCertificateTemplateFuncInvoked = false
err := svc.ResendHostCertificateTemplate(ctx, hostID, templateID)
require.Error(t, err)
var umErr interface{ StatusCode() int }
require.ErrorAs(t, err, &umErr)
require.Equal(t, 400, umErr.StatusCode())
require.False(t, ds.ResendHostCertificateTemplateFuncInvoked)
require.False(t, opts.ActivityMock.NewActivityFuncInvoked)
})
}