add end user BYOD enrollment into Fleet MDM (#21836)

relates to #19448

Adds the ability for a user to enroll a their device into fleet MDM.

> NOTE: this is the PR for the feature branch to go into main so all
code has already been approved.
This commit is contained in:
Roberto Dip 2024-09-05 11:24:06 -03:00 committed by GitHub
commit c0373cbe51
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
26 changed files with 596 additions and 51 deletions

Binary file not shown.

After

Width:  |  Height:  |  Size: 68 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 118 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 82 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 93 KiB

View file

@ -0,0 +1 @@
- Adds an endpoint for getting an OTA MDM profile for enrolling iOS and iPadOS hosts.

View file

@ -0,0 +1 @@
- add feature for end users to enroll their device into fleet mdm

View file

@ -967,7 +967,7 @@ the way that the Fleet server works.
KeyPrefix: "ratelimit::",
}
var apiHandler, frontendHandler http.Handler
var apiHandler, frontendHandler, endUserEnrollOTAHandler http.Handler
{
frontendHandler = service.PrometheusMetricsHandler(
"get_frontend",
@ -985,8 +985,10 @@ the way that the Fleet server works.
if setupRequired {
apiHandler = service.WithSetup(svc, logger, apiHandler)
frontendHandler = service.RedirectLoginToSetup(svc, logger, frontendHandler, config.Server.URLPrefix)
endUserEnrollOTAHandler = service.RedirectLoginToSetup(svc, logger, frontendHandler, config.Server.URLPrefix)
} else {
frontendHandler = service.RedirectSetupToLogin(svc, logger, frontendHandler, config.Server.URLPrefix)
endUserEnrollOTAHandler = service.ServeEndUserEnrollOTA(config.Server.URLPrefix, logger)
}
}
@ -1121,6 +1123,7 @@ the way that the Fleet server works.
}
apiHandler.ServeHTTP(rw, req)
})
rootMux.Handle("/enroll", endUserEnrollOTAHandler)
rootMux.Handle("/", frontendHandler)
debugHandler := &debugMux{

View file

@ -73,8 +73,9 @@ describe("AddHostsModal", () => {
expect(screen.queryByText(/--enable-scripts/i)).not.toBeInTheDocument();
await user.click(screen.getByRole("tab", { name: "iOS & iPadOS" }));
expect(screen.queryByText(/Apple Business Manager/i)).toBeInTheDocument();
expect(screen.queryByText(/Learn more/i)).toBeInTheDocument();
expect(
screen.queryByText(/Send this to your end users:/i)
).toBeInTheDocument();
await user.click(screen.getByRole("tab", { name: "Advanced" }));
const advancedText = screen.getByText(/--type=YOUR_TYPE/i);

View file

@ -9,7 +9,6 @@ import Modal from "components/Modal";
import Spinner from "components/Spinner";
import PlatformWrapper from "./PlatformWrapper/PlatformWrapper";
import DownloadInstallers from "./DownloadInstallers/DownloadInstallers";
const baseClass = "add-hosts-modal";

View file

@ -0,0 +1,46 @@
import React, { useContext } from "react";
import { AppContext } from "context/app";
// @ts-ignore
import InputField from "components/forms/fields/InputField";
const generateUrl = (serverUrl: string, enrollSecret: string) => {
return `${serverUrl}/enroll?enroll_secret=${enrollSecret}`;
};
const baseClass = "ios-ipados-panel";
interface IosIpadosPanelProps {
enrollSecret: string;
}
const IosIpadosPanel = ({ enrollSecret }: IosIpadosPanelProps) => {
const { config } = useContext(AppContext);
const helpText =
"When the end user navigates to this URL, the enrollment profile " +
"will download in their browser. End users will have to install the profile " +
"to enroll to Fleet.";
if (!config) return null;
const url = generateUrl(config.server_settings.server_url, enrollSecret);
return (
<div className={baseClass}>
<InputField
label="Send this to your end users:"
enableCopy
copyButtonPosition="inside"
readOnly
inputWrapperClass
name="enroll-link"
value={url}
helpText={helpText}
/>
</div>
);
};
export default IosIpadosPanel;

View file

@ -0,0 +1,5 @@
.ios-ipados-panel {
&__spinner {
margin: $pad-xxlarge auto;
}
}

View file

@ -0,0 +1 @@
export { default } from "./IosIpadosPanel";

View file

@ -19,6 +19,7 @@ import InfoBanner from "components/InfoBanner/InfoBanner";
import CustomLink from "components/CustomLink/CustomLink";
import { isValidPemCertificate } from "../../../pages/hosts/ManageHostsPage/helpers";
import IosIpadosPanel from "./IosIpadosPanel";
interface IPlatformSubNav {
name: string;
@ -324,7 +325,7 @@ const PlatformWrapper = ({
);
};
const renderTab = (packageType: string) => {
const renderPanel = (packageType: string) => {
const CHROME_OS_INFO = {
extensionId: "fleeedmmihkfkeemmipgmhhjemlljidg",
installationUrl: "https://chrome.fleetdm.com/updates.xml",
@ -395,19 +396,7 @@ const PlatformWrapper = ({
}
if (packageType === "ios-ipados") {
return (
<div className={`${baseClass}__ios-ipados--info`}>
<p>
Enroll iPhones and iPads by adding them to Fleet in Apple Business
Manager (ABM).{" "}
<CustomLink
url="https://fleetdm.com/learn-more-about/setup-abm"
text="Learn more"
newTab
/>
</p>
</div>
);
return <IosIpadosPanel enrollSecret={enrollSecret} />;
}
if (packageType === "advanced") {
@ -590,7 +579,7 @@ const PlatformWrapper = ({
return (
<TabPanel className={`${baseClass}__info`} key={navItem.type}>
<div className={`${baseClass} form`}>
{renderTab(navItem.type)}
{renderPanel(navItem.type)}
</div>
</TabPanel>
);

View file

@ -43,6 +43,7 @@ class InputField extends Component {
PropTypes.object,
]),
enableCopy: PropTypes.bool,
copyButtonPosition: PropTypes.oneOfType(["inside", "outside"]),
ignore1password: PropTypes.bool,
};
@ -62,6 +63,7 @@ class InputField extends Component {
labelTooltipPosition: undefined,
helpText: "",
enableCopy: false,
copyButtonPosition: "outside",
ignore1password: false,
};
@ -97,6 +99,59 @@ class InputField extends Component {
return onChange(value);
};
renderCopyButton = () => {
const { value, copyButtonPosition } = this.props;
const copyValue = (e) => {
e.preventDefault();
stringToClipboard(value).then(() => {
this.setState({ copied: true });
setTimeout(() => {
this.setState({ copied: false });
}, 2000);
});
};
const copyButtonValue =
copyButtonPosition === "outside" ? (
<>
<Icon name="copy" />
<span>Copy</span>
</>
) : (
<Icon name="copy" />
);
const wrapperClasses = classnames(
`${baseClass}__copy-wrapper`,
copyButtonPosition === "outside"
? `${baseClass}__copy-wrapper-outside`
: `${baseClass}__copy-wrapper-inside`
);
const copiedConfirmationClasses = classnames(
`${baseClass}__copied-confirmation`,
copyButtonPosition === "outside"
? `${baseClass}__copied-confirmation-outside`
: `${baseClass}__copied-confirmation-inside`
);
return (
<div className={wrapperClasses}>
<Button
variant="text-icon"
onClick={copyValue}
className={`${baseClass}__copy-value-button`}
>
{copyButtonValue}
</Button>
{this.state.copied && (
<span className={copiedConfirmationClasses}>Copied!</span>
)}
</div>
);
};
render() {
const {
readOnly,
@ -113,6 +168,8 @@ class InputField extends Component {
blockAutoComplete,
value,
ignore1password,
enableCopy,
copyButtonPosition,
} = this.props;
const { onInputChange } = this;
@ -139,16 +196,6 @@ class InputField extends Component {
"labelTooltipPosition",
]);
const copyValue = (e) => {
e.preventDefault();
stringToClipboard(value).then(() => {
this.setState({ copied: true });
setTimeout(() => {
this.setState({ copied: false });
}, 2000);
});
};
if (type === "textarea") {
return (
<FormField
@ -175,7 +222,9 @@ class InputField extends Component {
}
const inputContainerClasses = classnames(`${baseClass}__input-container`, {
"copy-enabled": this.props.enableCopy,
"copy-enabled": enableCopy,
"copy-outside": enableCopy && copyButtonPosition === "outside",
"copy-inside": enableCopy && copyButtonPosition === "inside",
});
return (
@ -203,22 +252,8 @@ class InputField extends Component {
autoComplete={blockAutoComplete ? "new-password" : ""}
data-1p-ignore={ignore1password}
/>
{this.props.enableCopy && (
<div className={`${baseClass}__copy-wrapper`}>
<Button
variant="text-icon"
onClick={copyValue}
className={`${baseClass}__copy-value-button`}
>
<Icon name="copy" /> Copy
</Button>
{this.state.copied && (
<span className={`${baseClass}__copied-confirmation`}>
Copied!
</span>
)}
</div>
)}
{enableCopy && this.renderCopyButton()}
</div>
</FormField>
);

View file

@ -8,3 +8,16 @@ const meta = {
export default meta;
export const Basic = {};
export const WithCopyEnabled = {
args: {
enableCopy: true,
},
};
export const WithCopyEnabledInsideInput = {
args: {
enableCopy: true,
copyButtonPosition: "inside",
},
};

View file

@ -86,17 +86,32 @@
}
}
&__copy-wrapper {
&__copy-wrapper-outside {
position: relative;
}
&__copy-wrapper-inside {
position: absolute;
top: 50%;
transform: translateY(-50%);
right: 12px;
}
&__input-container.copy-enabled {
display: flex;
align-items: center;
gap: $pad-medium;
&.copy-outside {
display: flex;
align-items: center;
gap: $pad-medium;
}
&.copy-inside {
position: relative;
}
}
&__copied-confirmation {
font-size: $x-small;
position: absolute;
background-color: $ui-light-grey;
border: solid 1px $ui-fleet-black-10;
@ -104,6 +119,13 @@
padding: $pad-xxsmall 6px;
top: 50%;
transform: translateY(-50%);
}
&__copied-confirmation-inside {
right: 24px;
}
&__copied-confirmation-outside {
left: -90px;
}
}

0
frontend/styles/byod.css Normal file
View file

View file

@ -0,0 +1,180 @@
<html>
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<meta name="robots" content="noindex" />
<link rel="shortcut icon" href="{{.URLPrefix}}/assets/favicon.ico" />
<title>Fleet</title>
<style>
@font-face {
font-family: "Inter";
font-weight: 400;
src: url("../assets/fonts/inter/Inter-Regular.woff2") format("woff2"),
url("../assets/fonts/inter/Inter-Regular.woff") format("woff");
}
@font-face {
font-family: "Inter";
font-weight: 700;
src: url("../assets/fonts/inter/Inter-Bold.woff2") format("woff2"),
url("../assets/fonts/inter/Inter-Bold.woff") format("woff");
}
html {
box-sizing: border-box;
font-family: "Inter", sans-serif;
color: #192147;
}
*,
*:before,
*:after {
box-sizing: inherit;
}
body,
h1,
p {
margin: 0;
}
.download-link {
color: #fff;
background-color: #6a67fe;
padding: 8px 16px;
border-radius: 4px;
text-decoration: none;
font-weight: 700;
font-size: 14px;
width: 100px;
line-height: 21px;
}
header {
background-color: #192147;
padding: 13px 20px;
}
h1 {
margin-bottom: 8px;
}
p {
font-size: 14px;
}
ol {
display: flex;
flex-direction: column;
gap: 48px;
list-style: none;
padding: 0;
}
li {
display: flex;
flex-direction: column;
gap: 20px;
}
li > p {
display: flex;
gap: 4px;
}
.page-description {
font-size: 16px;
margin-bottom: 48px;
}
.profile-downloaded-container,
.install-profile-container {
width: 100%;
background-color: #f9fafc;
text-align: center;
}
.profile-downloaded-img,
.install-profile-img {
max-width: 100%;
}
.profile-downloaded-img {
max-height: 150px;
}
.install-profile-img {
max-height: 118px;
}
#main-content {
padding: 48px 24px;
}
</style>
</head>
<body>
<header>
<img src="{{.URLPrefix}}/assets/images/fleet-logo.svg" />
</header>
<section id="main-content">
<h1>Enroll your device to Fleet</h1>
<p class="page-description">
Follow the instructions below to download and install the Fleet profile
on your device.
</p>
<ol>
<li>
<p>
<span>1.</span>
<span>
<b>Download</b> the Fleet profile and select <b>Allow</b> in the
pop-up.
</span>
</p>
<a class="download-link" href="{{.EnrollURL}}">Download</a>
</li>
<li>
<p>
<span>2.</span>
<span>
Navigate to <b>Settings</b> and select <b>Profile Downloaded</b>.
</span>
</p>
<div class="profile-downloaded-container">
<img
class="profile-downloaded-img"
src=""
alt="select profile downloaded in settings"
/>
</div>
</li>
<li>
<p>
<span>3.</span>
<span>Select <b>Install</b>.</span>
</p>
<div class="install-profile-container">
<img class="install-profile-img" src="" alt="select install" />
</div>
</li>
</ol>
</section>
<script>
const os = navigator.userAgent.includes("iPhone") ? "ios" : "iPadOS";
const profileDownloadedImg = document.querySelector(
".profile-downloaded-img"
);
const installProfileImg = document.querySelector(".install-profile-img");
// setting image src based on OS
profileDownloadedImg.setAttribute(
"src",
`{{.URLPrefix}}/assets/images/${os}-profile-downloaded.png`
);
installProfileImg.setAttribute(
"src",
`{{.URLPrefix}}/assets/images/${os}-install-profile.png`
);
</script>
</body>
</html>

View file

@ -930,6 +930,9 @@ type Service interface {
// CheckMDMAppleEnrollmentWithMinimumOSVersion checks if the minimum OS version is met for a MDM enrollment
CheckMDMAppleEnrollmentWithMinimumOSVersion(ctx context.Context, m *MDMAppleMachineInfo) (*MDMAppleSoftwareUpdateRequired, error)
// GetOTAProfile gets the OTA (over-the-air) profile for a given team based on the enroll secret provided.
GetOTAProfile(ctx context.Context, enrollSecret string) ([]byte, error)
///////////////////////////////////////////////////////////////////////////////
// CronSchedulesService

View file

@ -1041,3 +1041,36 @@ func IOSiPadOSRefetch(ctx context.Context, ds fleet.Datastore, commander *MDMApp
}
return nil
}
func GenerateOTAEnrollmentProfileMobileconfig(orgName, fleetURL, enrollSecret string) ([]byte, error) {
path, err := url.JoinPath(fleetURL, "/api/v1/fleet/ota_enrollment")
if err != nil {
return nil, fmt.Errorf("creating path for ota enrollment url: %w", err)
}
enrollURL, err := url.Parse(path)
if err != nil {
return nil, fmt.Errorf("parsing ota enrollment url: %w", err)
}
q := enrollURL.Query()
q.Set("enroll_secret", enrollSecret)
enrollURL.RawQuery = q.Encode()
var profileBuf bytes.Buffer
tmplArgs := struct {
Organization string
URL string
EnrollSecret string
}{
Organization: orgName,
URL: enrollURL.String(),
}
err = mobileconfig.OTAMobileConfigTemplate.Execute(&profileBuf, tmplArgs)
if err != nil {
return nil, fmt.Errorf("executing ota profile template: %w", err)
}
return profileBuf.Bytes(), nil
}

View file

@ -1,6 +1,11 @@
package mobileconfig
import "text/template"
import (
"encoding/xml"
"fmt"
"strings"
"text/template"
)
var funcMap = map[string]any{
"xml": XMLEscapeString,
@ -113,3 +118,40 @@ var FleetCARootTemplate = template.Must(template.New("").Option("missingkey=erro
</dict>
</plist>
`))
var OTAMobileConfigTemplate = template.Must(template.New("").Funcs(template.FuncMap{"xml": func(v string) (string, error) {
var escaped strings.Builder
if err := xml.EscapeText(&escaped, []byte(v)); err != nil {
return "", fmt.Errorf("XML escaping in OTA profile: %w", err)
}
return escaped.String(), nil
}}).Option("missingkey=error").Parse(`<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple Inc//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>PayloadContent</key>
<dict>
<key>URL</key>
<string>{{ .URL }}</string>
<key>DeviceAttributes</key>
<array>
<string>UDID</string>
<string>VERSION</string>
<string>PRODUCT</string>
<string>SERIAL</string>
</array>
</dict>
<key>PayloadOrganization</key>
<string>{{ xml .Organization }}</string>
<key>PayloadDisplayName</key>
<string>{{ xml .Organization }} enrollment</string>
<key>PayloadVersion</key>
<integer>1</integer>
<key>PayloadUUID</key>
<string>fdb376e5-b5bb-4d8c-829e-e90865f990c9</string>
<key>PayloadIdentifier</key>
<string>com.fleetdm.fleet.mdm.apple.ota</string>
<key>PayloadType</key>
<string>Profile Service</string>
</dict>
</plist>`))

View file

@ -4147,3 +4147,45 @@ func (svc *Service) RenewABMToken(ctx context.Context, token io.Reader, tokenID
return nil, fleet.ErrMissingLicense
}
////////////////////////////////////////////////////////////////////////////////
// GET /enrollment_profiles/ota
////////////////////////////////////////////////////////////////////////////////
type getOTAProfileRequest struct {
EnrollSecret string `query:"enroll_secret"`
}
func getOTAProfileEndpoint(ctx context.Context, request interface{}, svc fleet.Service) (errorer, error) {
req := request.(*getOTAProfileRequest)
profile, err := svc.GetOTAProfile(ctx, req.EnrollSecret)
if err != nil {
return &getMDMAppleConfigProfileResponse{Err: err}, err
}
reader := bytes.NewReader(profile)
return &getMDMAppleConfigProfileResponse{fileReader: io.NopCloser(reader), fileLength: reader.Size(), fileName: "fleet-mdm-enrollment-profile"}, nil
}
func (svc *Service) GetOTAProfile(ctx context.Context, enrollSecret string) ([]byte, error) {
// Skip authz as this endpoint is used by end users from their iPhones or iPads; authz is done
// by the enroll secret verification below
svc.authz.SkipAuthorization(ctx)
cfg, err := svc.ds.AppConfig(ctx)
if err != nil {
return nil, ctxerr.Wrap(ctx, err, "getting app config to get org name")
}
profBytes, err := apple_mdm.GenerateOTAEnrollmentProfileMobileconfig(cfg.OrgInfo.OrgName, cfg.ServerSettings.ServerURL, enrollSecret)
if err != nil {
return nil, ctxerr.Wrap(ctx, err, "generating ota mobileconfig file")
}
signed, err := mdmcrypto.Sign(ctx, profBytes, svc.ds)
if err != nil {
return nil, ctxerr.Wrap(ctx, err, "signing profile")
}
return signed, nil
}

View file

@ -1,9 +1,11 @@
package service
import (
"fmt"
"html/template"
"io"
"net/http"
"net/url"
assetfs "github.com/elazarl/go-bindata-assetfs"
"github.com/fleetdm/fleet/v4/server/bindata"
@ -68,6 +70,69 @@ func ServeFrontend(urlPrefix string, sandbox bool, logger log.Logger) http.Handl
})
}
func ServeEndUserEnrollOTA(urlPrefix string, logger log.Logger) http.Handler {
herr := func(w http.ResponseWriter, err string) {
logger.Log("err", err)
http.Error(w, err, http.StatusInternalServerError)
}
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
writeBrowserSecurityHeaders(w)
fs := newBinaryFileSystem("/frontend")
file, err := fs.Open("templates/enroll-ota.html")
if err != nil {
herr(w, "load enroll ota template: "+err.Error())
return
}
data, err := io.ReadAll(file)
if err != nil {
herr(w, "read bindata file: "+err.Error())
return
}
t, err := template.New("enroll-ota").Parse(string(data))
if err != nil {
herr(w, "create react template: "+err.Error())
return
}
enrollURL, err := generateEnrollOTAURL(urlPrefix, r.URL.Query().Get("enroll_secret"))
if err != nil {
herr(w, "generate enroll ota url: "+err.Error())
return
}
if err := t.Execute(w, struct {
EnrollURL string
URLPrefix string
}{
URLPrefix: urlPrefix,
EnrollURL: enrollURL,
}); err != nil {
herr(w, "execute react template: "+err.Error())
return
}
})
}
func generateEnrollOTAURL(fleetURL string, enrollSecret string) (string, error) {
path, err := url.JoinPath(fleetURL, "/api/v1/fleet/enrollment_profiles/ota")
if err != nil {
return "", fmt.Errorf("creating path for end user ota enrollment url: %w", err)
}
enrollURL, err := url.Parse(path)
if err != nil {
return "", fmt.Errorf("parsing end user ota enrollment url: %w", err)
}
q := enrollURL.Query()
q.Set("enroll_secret", enrollSecret)
enrollURL.RawQuery = q.Encode()
return enrollURL.String(), nil
}
func ServeStaticAssets(path string) http.Handler {
return http.StripPrefix(path, http.FileServer(newBinaryFileSystem("/assets")))
}

View file

@ -2,6 +2,7 @@ package service
import (
"bytes"
"io"
"net/http"
"net/http/httptest"
"os"
@ -40,3 +41,29 @@ func TestServeFrontend(t *testing.T) {
require.NoError(t, err)
require.Equal(t, http.StatusMethodNotAllowed, response.StatusCode)
}
func TestServeEndUserEnrollOTA(t *testing.T) {
if !hasBuildTag("full") {
t.Skip("This test requires running with -tags full")
}
logger := log.NewLogfmtLogger(os.Stdout)
h := ServeEndUserEnrollOTA("", logger)
ts := httptest.NewServer(h)
t.Cleanup(func() {
ts.Close()
})
// assert html is returned
response, err := http.DefaultClient.Get(ts.URL + "?enroll_secret=foo")
require.NoError(t, err)
require.Equal(t, http.StatusOK, response.StatusCode)
require.Equal(t, response.Header.Get("Content-Type"), "text/html; charset=utf-8")
// assert it contains the content we expect
defer response.Body.Close()
bodyBytes, err := io.ReadAll(response.Body)
require.NoError(t, err)
bodyString := string(bodyBytes)
require.Contains(t, bodyString, "Enroll your device to Fleet")
require.Contains(t, bodyString, "?enroll_secret=foo")
}

View file

@ -887,6 +887,9 @@ func attachFleetAPIRoutes(r *mux.Router, svc fleet.Service, config config.FleetC
// Deprecated: GET /mdm/apple/setup/eula/:token is now deprecated, replaced by the platform agnostic /mdm/setup/eula/:token
neAppleMDM.GET("/api/_version_/fleet/mdm/apple/setup/eula/{token}", getMDMEULAEndpoint, getMDMEULARequest{})
// Get OTA profile
neAppleMDM.GET("/api/_version_/fleet/enrollment_profiles/ota", getOTAProfileEndpoint, getOTAProfileRequest{})
// These endpoint are used by Microsoft devices during MDM device enrollment phase
neWindowsMDM := ne.WithCustomMiddleware(mdmConfiguredMiddleware.VerifyWindowsMDM())

View file

@ -10,6 +10,7 @@ import (
"fmt"
"io"
"net/http"
"net/url"
"sort"
"strconv"
"strings"
@ -4814,3 +4815,36 @@ func (s *integrationMDMTestSuite) TestHostMDMProfilesExcludeLabels() {
},
})
}
func (s *integrationMDMTestSuite) TestOTAProfile() {
t := s.T()
ctx := context.Background()
// Getting profile for non-existent secret it's ok
s.Do("GET", "/api/latest/fleet/enrollment_profiles/ota", getOTAProfileRequest{}, http.StatusOK, "enroll_secret", "not-real")
// Create an enroll secret; has some special characters that should be escaped in the profile
globalEnrollSec := "global_enroll+_/sec"
escSec := url.QueryEscape(globalEnrollSec)
s.Do("POST", "/api/latest/fleet/spec/enroll_secret", applyEnrollSecretSpecRequest{
Spec: &fleet.EnrollSecretSpec{
Secrets: []*fleet.EnrollSecret{{Secret: globalEnrollSec}},
},
}, http.StatusOK)
cfg, err := s.ds.AppConfig(ctx)
require.NoError(t, err)
// Get profile with that enroll secret
resp := s.Do("GET", "/api/latest/fleet/enrollment_profiles/ota", getOTAProfileRequest{}, http.StatusOK, "enroll_secret", globalEnrollSec)
require.NotZero(t, resp.ContentLength)
require.Contains(t, resp.Header.Get("Content-Disposition"), `attachment;filename="fleet-mdm-enrollment-profile.mobileconfig"`)
require.Contains(t, resp.Header.Get("Content-Type"), "application/x-apple-aspen-config")
require.Contains(t, resp.Header.Get("X-Content-Type-Options"), "nosniff")
b, err := io.ReadAll(resp.Body)
require.NoError(t, err)
require.Equal(t, resp.ContentLength, int64(len(b)))
require.Contains(t, string(b), "com.fleetdm.fleet.mdm.apple.ota")
require.Contains(t, string(b), fmt.Sprintf("%s/api/v1/fleet/ota_enrollment?enroll_secret=%s", cfg.ServerSettings.ServerURL, escSec))
require.Contains(t, string(b), cfg.OrgInfo.OrgName)
}