fleet/server/service/integrationtest/android/android_test.go
Magnus Jensen 9a859736c2
IdP Authentication before BYOD (#32017)
fixes: #29222 

This is a feature branch that was completed last week, but did not get
merged in time.

All pr's going in was approved, and reviewed.

I will after this is merged, do a cherry pick onto the RC 4.73 branch,
and initiate the FR merge process.

---------

Co-authored-by: Martin Angers <martin.n.angers@gmail.com>
Co-authored-by: Sarah Gillespie <73313222+gillespi314@users.noreply.github.com>
Co-authored-by: Gabriel Hernandez <ghernandez345@gmail.com>
2025-08-18 18:31:53 +02:00

271 lines
8.6 KiB
Go

package android
import (
"context"
"encoding/base64"
"encoding/json"
"fmt"
"io"
"net/http"
"testing"
shared_mdm "github.com/fleetdm/fleet/v4/pkg/mdm"
"github.com/fleetdm/fleet/v4/server"
"github.com/fleetdm/fleet/v4/server/datastore/mysql"
"github.com/fleetdm/fleet/v4/server/fleet"
"github.com/fleetdm/fleet/v4/server/mdm/android"
"github.com/fleetdm/fleet/v4/server/service/middleware/endpoint_utils"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"google.golang.org/api/androidmanagement/v1"
)
func TestAndroid(t *testing.T) {
s := SetUpSuite(t, "integrationtest.Android")
cases := []struct {
name string
fn func(t *testing.T, s *Suite)
}{
{"HappyPath", testHappyPath},
{"CreateEnrollmentToken", testCreateEnrollmentToken},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
defer mysql.TruncateTables(t, s.DS)
c.fn(t, s)
})
}
}
func testHappyPath(t *testing.T, s *Suite) {
signupDetails := expectSignupDetails(t, s)
var signupURL android.EnterpriseSignupResponse
s.DoJSON(t, "GET", "/api/v1/fleet/android_enterprise/signup_url", nil, http.StatusOK, &signupURL)
assert.Equal(t, signupURL.Url, signupDetails.Url)
}
type enrollmentTokenRequest struct {
EnrollSecret string
IdpUUID string
}
func testCreateEnrollmentToken(t *testing.T, s *Suite) {
appCfg := &fleet.AppConfig{
MDM: fleet.MDM{
AndroidEnabledAndConfigured: true,
},
ServerSettings: fleet.ServerSettings{
ServerURL: "http://localhost",
},
}
enableAndroidMDM := func() {
_, err := s.DS.NewAppConfig(t.Context(), appCfg)
require.NoError(t, err)
}
createTeamAndSecret := func(name, secret string, enableEndUserAuth bool) {
team, err := s.DS.NewTeam(t.Context(), &fleet.Team{
Name: name,
Config: fleet.TeamConfig{
MDM: fleet.TeamMDM{
MacOSSetup: fleet.MacOSSetup{
EnableEndUserAuthentication: enableEndUserAuth,
},
},
},
})
require.NoError(t, err)
err = s.DS.ApplyEnrollSecrets(t.Context(), &team.ID, []*fleet.EnrollSecret{
{
Secret: secret,
TeamID: &team.ID,
},
})
require.NoError(t, err)
}
setupAndroidEnterprise := func() {
admin := s.Users["admin1"]
enterpriseID, err := s.DS.CreateEnterprise(t.Context(), admin.ID)
require.NoError(t, err)
// signupToken is used to authenticate the signup callback URL -- to ensure that the callback came from our Android enterprise signup flow
signupToken, err := server.GenerateRandomURLSafeText(32)
require.NoError(t, err)
callbackURL := fmt.Sprintf("%s/api/v1/fleet/android_enterprise/connect/%s", appCfg.ServerSettings.ServerURL, signupToken)
signupDetails := android.SignupDetails{
Name: "test",
Url: callbackURL,
}
err = s.DS.UpdateEnterprise(t.Context(), &android.EnterpriseDetails{
Enterprise: android.Enterprise{
ID: enterpriseID,
EnterpriseID: "test",
},
SignupName: signupDetails.Name,
SignupToken: signupToken,
})
require.NoError(t, err)
}
s.AndroidProxy.EnterprisesEnrollmentTokensCreateFunc = func(ctx context.Context, enterpriseName string, token *androidmanagement.EnrollmentToken) (*androidmanagement.EnrollmentToken, error) {
// For ease of testing and validating, we base64 the json input as the output value
jsonString, err := json.Marshal(token)
require.NoError(t, err)
base64Encoded := base64.StdEncoding.EncodeToString(jsonString)
return &androidmanagement.EnrollmentToken{
Value: base64Encoded,
}, nil
}
t.Run("fails", func(t *testing.T) {
t.Run("if enroll_secret query param is missing", func(t *testing.T) {
s.Do(t, "GET", "/api/v1/fleet/android_enterprise/enrollment_token", nil, http.StatusBadRequest)
})
t.Run("if android MDM is not configured", func(t *testing.T) {
s.Do(t, "GET", "/api/v1/fleet/android_enterprise/enrollment_token", nil, http.StatusConflict, "enroll_secret", "secret")
})
t.Run("if enroll secret is invalid", func(t *testing.T) {
enableAndroidMDM()
s.Do(t, "GET", "/api/v1/fleet/android_enterprise/enrollment_token", nil, http.StatusUnauthorized, "enroll_secret", "secret")
})
t.Run("if android enterprise is missing", func(t *testing.T) {
enableAndroidMDM()
secret := "global-enterprise-missing"
createTeamAndSecret(secret, secret, false)
resp := s.Do(t, "GET", "/api/v1/fleet/android_enterprise/enrollment_token", nil, http.StatusNotFound, "enroll_secret", secret)
je := decodeJsonError(t, resp)
require.Contains(t, "Android enterprise", je.Errors[0]["base"])
mysql.TruncateTables(t, s.DS)
})
t.Run("if idp account does not exist", func(t *testing.T) {
enableAndroidMDM()
secret := "global-no-idp-account" // nolint: gosec
createTeamAndSecret(secret, secret, false)
resp := s.DoRawWithHeaders(t, "GET", "/api/v1/fleet/android_enterprise/enrollment_token", nil, http.StatusUnprocessableEntity, map[string]string{
"Cookie": fmt.Sprintf("%s=%s", shared_mdm.BYODIdpCookieName, "test-uuid"),
}, "enroll_secret", secret)
je := decodeJsonError(t, resp)
require.Contains(t, "validating idp account existence", je.Errors[0]["base"])
mysql.TruncateTables(t, s.DS)
})
t.Run("if idp is required but not set", func(t *testing.T) {
enableAndroidMDM()
secret := "team"
createTeamAndSecret("team", secret, true)
s.DoRaw(t, "GET", "/api/v1/fleet/android_enterprise/enrollment_token", nil, http.StatusUnauthorized, "enroll_secret", secret)
})
t.Cleanup(func() {
mysql.TruncateTables(t, s.DS)
})
})
t.Run("succeeds", func(t *testing.T) {
globalSecret := "global"
t.Run("when enroll secret is passed", func(t *testing.T) {
enableAndroidMDM()
createTeamAndSecret(globalSecret, globalSecret, false)
setupAndroidEnterprise()
var resp android.EnrollmentTokenResponse
s.DoJSON(t, "GET", "/api/v1/fleet/android_enterprise/enrollment_token", nil, http.StatusOK, &resp, "enroll_secret", globalSecret)
decoded, err := base64.StdEncoding.DecodeString(resp.EnrollmentToken.EnrollmentToken)
require.NoError(t, err)
var et androidmanagement.EnrollmentToken
err = json.Unmarshal(decoded, &et)
require.NoError(t, err)
var enrollmentRequest enrollmentTokenRequest
err = json.Unmarshal([]byte(et.AdditionalData), &enrollmentRequest)
require.NoError(t, err)
require.Equal(t, globalSecret, enrollmentRequest.EnrollSecret)
require.Equal(t, "", enrollmentRequest.IdpUUID)
t.Cleanup(func() {
mysql.TruncateTables(t, s.DS)
})
})
t.Run("when enroll and idp uuid is set", func(t *testing.T) {
enableAndroidMDM()
createTeamAndSecret(globalSecret, globalSecret, true)
setupAndroidEnterprise()
idpEmail := "test@local.com"
err := s.DS.InsertMDMIdPAccount(t.Context(), &fleet.MDMIdPAccount{
Username: "test",
Email: idpEmail,
})
require.NoError(t, err)
idpAccount, err := s.DS.GetMDMIdPAccountByEmail(t.Context(), idpEmail)
require.NoError(t, err)
resp := s.DoRawWithHeaders(t, "GET", "/api/v1/fleet/android_enterprise/enrollment_token", nil, http.StatusOK, map[string]string{
"Cookie": fmt.Sprintf("%s=%s", shared_mdm.BYODIdpCookieName, idpAccount.UUID),
}, "enroll_secret", globalSecret)
bodyBytes, err := io.ReadAll(resp.Body)
require.NoError(t, err)
var etr android.EnrollmentTokenResponse
err = json.Unmarshal(bodyBytes, &etr)
require.NoError(t, err)
decoded, err := base64.StdEncoding.DecodeString(etr.EnrollmentToken.EnrollmentToken)
require.NoError(t, err)
var et androidmanagement.EnrollmentToken
err = json.Unmarshal(decoded, &et)
require.NoError(t, err)
var enrollmentRequest enrollmentTokenRequest
err = json.Unmarshal([]byte(et.AdditionalData), &enrollmentRequest)
require.NoError(t, err)
require.Equal(t, globalSecret, enrollmentRequest.EnrollSecret)
require.Equal(t, idpAccount.UUID, enrollmentRequest.IdpUUID)
t.Cleanup(func() {
mysql.TruncateTables(t, s.DS)
})
})
})
}
func expectSignupDetails(t *testing.T, s *Suite) *android.SignupDetails {
signupDetails := &android.SignupDetails{
Url: "URL",
Name: "Name",
}
s.AndroidProxy.SignupURLsCreateFunc = func(_ context.Context, serverURL, callbackURL string) (*android.SignupDetails, error) {
assert.Equal(t, s.Server.URL, serverURL)
// We will need to extract the security token from the callbackURL for further testing
assert.Contains(t, callbackURL, "/api/v1/fleet/android_enterprise/connect/")
return signupDetails, nil
}
return signupDetails
}
func decodeJsonError(t *testing.T, response *http.Response) endpoint_utils.JsonError {
bodyBytes, err := io.ReadAll(response.Body)
require.NoError(t, err)
var je endpoint_utils.JsonError
err = json.Unmarshal(bodyBytes, &je)
require.NoError(t, err)
return je
}