<!-- Add the related story/sub-task/bug number, like Resolves #123, or remove if NA --> **Related issue:** Resolves #44333 # Checklist for submitter - [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. ## Testing - [x] Added/updated automated tests. Also added some integration tests as a follow-up of the first PR (https://github.com/fleetdm/fleet/pull/44390). - [x] QA'd all new/changed functionality manually #### generate-gitops - Branched off to main, no URLs set, then ran generate-gitops on this branch. Deprecated keys gone, new keys present. <img width="447" height="170" alt="nourls_new" src="https://github.com/user-attachments/assets/61931615-d61b-44d3-8095-f7a2b9bd8871" /> - Branched off to main, set external URLs for both light and dark modes, then ran generate-gitops on this branch. Deprecated keys gone, new keys set with the external URLs. <img width="637" height="471" alt="externalurl_main" src="https://github.com/user-attachments/assets/c3782756-acc2-4b99-812d-86e145f11ad5" /> <img width="459" height="168" alt="externalurl_new" src="https://github.com/user-attachments/assets/aa2d8825-3c47-40ba-ab91-bb8202afe81a" /> - Within this branch, after uploading a custom logo for light mode, ran generate-gitops. The logo was saved in lib/org_logo/light.webp <img width="1510" height="639" alt="Screenshot 2026-05-04 at 4 06 59 PM" src="https://github.com/user-attachments/assets/13318c24-8fa4-4e29-b629-ff723d4afe5a" /> <img width="786" height="172" alt="Screenshot 2026-05-04 at 4 07 30 PM" src="https://github.com/user-attachments/assets/b46bd1df-7dcd-4489-b7da-4cbad77b25b8" /> #### gitops - Applied gitops with two external URLs. Verified in the UI that those are still present <img width="944" height="189" alt="Screenshot 2026-05-04 at 7 54 53 AM" src="https://github.com/user-attachments/assets/a34813ca-beb1-403e-9793-d42cc9c72f8b" /> <img width="637" height="259" alt="Screenshot 2026-05-04 at 8 01 04 AM" src="https://github.com/user-attachments/assets/74c2cd56-ab1d-4ddd-9b8e-22c49e9ae9d5" /> - Applied gitops with "" as the URLs to clear them. Verified the default fleet logo is shown. <img width="460" height="201" alt="Screenshot 2026-05-04 at 8 15 11 AM" src="https://github.com/user-attachments/assets/dcbafea3-b4ea-44aa-9045-08c4f5a64e98" /> <img width="648" height="269" alt="Screenshot 2026-05-04 at 8 15 50 AM" src="https://github.com/user-attachments/assets/451a28f9-e929-4b84-93d3-a7dd9afd5eca" /> - Applied gitops with a custom logo for light theme, using **org_logo_path_light_mode**: <img width="948" height="207" alt="Screenshot 2026-05-04 at 4 10 05 PM" src="https://github.com/user-attachments/assets/b1418cd4-31cc-4e53-b566-9af11ec21970" /> <img width="774" height="168" alt="Screenshot 2026-05-04 at 4 10 35 PM" src="https://github.com/user-attachments/assets/63f596eb-308f-4122-ad86-e1d718e9b525" /> ## 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) - See https://github.com/fleetdm/fleet/pull/43808. - [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) - [x] 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** * GitOps support for uploading custom org logos (dark/light) via local files. * `fleetctl generate-gitops` exports Fleet-hosted logos as local files and inserts path references. * New API endpoints to upload, delete, and fetch org logos. * **Deprecated** * Legacy logo keys consolidated into mode-specific URL keys (`org_logo_url_dark_mode`, `org_logo_url_light_mode`). * **Bug Fixes / Validation** * Validation/error when both a path and URL are provided for the same mode; file size and image-format checks enforced. <!-- end of auto-generated comment: release notes by coderabbit.ai -->
274 lines
9.9 KiB
Go
274 lines
9.9 KiB
Go
package service
|
|
|
|
import (
|
|
"bytes"
|
|
"fmt"
|
|
"image"
|
|
"image/color"
|
|
"image/jpeg"
|
|
"image/png"
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
"testing"
|
|
|
|
"github.com/fleetdm/fleet/v4/server/fleet"
|
|
"github.com/stretchr/testify/assert"
|
|
"github.com/stretchr/testify/require"
|
|
)
|
|
|
|
func makePNG(t *testing.T) []byte {
|
|
t.Helper()
|
|
img := image.NewRGBA(image.Rect(0, 0, 1, 1))
|
|
img.Set(0, 0, color.RGBA{R: 0, G: 128, B: 0, A: 255})
|
|
var buf bytes.Buffer
|
|
require.NoError(t, png.Encode(&buf, img))
|
|
return buf.Bytes()
|
|
}
|
|
|
|
func makeJPEG(t *testing.T) []byte {
|
|
t.Helper()
|
|
img := image.NewRGBA(image.Rect(0, 0, 1, 1))
|
|
img.Set(0, 0, color.RGBA{R: 0, G: 128, B: 0, A: 255})
|
|
var buf bytes.Buffer
|
|
require.NoError(t, jpeg.Encode(&buf, img, nil))
|
|
return buf.Bytes()
|
|
}
|
|
|
|
func writeTempFile(t *testing.T, name string, body []byte) string {
|
|
t.Helper()
|
|
path := filepath.Join(t.TempDir(), name)
|
|
require.NoError(t, os.WriteFile(path, body, 0o600))
|
|
return path
|
|
}
|
|
|
|
func TestValidateOrgLogoFile(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
t.Run("accepts png", func(t *testing.T) {
|
|
assert.NoError(t, validateOrgLogoFile(writeTempFile(t, "logo.png", makePNG(t))))
|
|
})
|
|
t.Run("accepts jpeg", func(t *testing.T) {
|
|
assert.NoError(t, validateOrgLogoFile(writeTempFile(t, "logo.jpg", makeJPEG(t))))
|
|
})
|
|
t.Run("rejects unknown format", func(t *testing.T) {
|
|
err := validateOrgLogoFile(writeTempFile(t, "logo.txt", []byte("not an image")))
|
|
require.Error(t, err)
|
|
assert.ErrorContains(t, err, "PNG, JPEG, or WebP")
|
|
})
|
|
t.Run("rejects oversized file", func(t *testing.T) {
|
|
// fleet.ValidateOrgLogoBytes fires its size check before
|
|
// image.DecodeConfig, so the body content doesn't need to
|
|
// decode as a real image.
|
|
body := make([]byte, orgLogoMaxFileSize+1)
|
|
err := validateOrgLogoFile(writeTempFile(t, "big.png", body))
|
|
require.Error(t, err)
|
|
assert.ErrorContains(t, err, "100KB or less")
|
|
})
|
|
t.Run("missing file", func(t *testing.T) {
|
|
err := validateOrgLogoFile(filepath.Join(t.TempDir(), "absent.png"))
|
|
require.Error(t, err)
|
|
})
|
|
}
|
|
|
|
func TestPlanAndStripOrgLogos(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
c := &Client{}
|
|
logFn := func(string, ...any) {}
|
|
dir := t.TempDir()
|
|
pngPath := filepath.Join(dir, "logo.png")
|
|
require.NoError(t, os.WriteFile(pngPath, makePNG(t), 0o600))
|
|
|
|
orgSettings := func(orgInfo map[string]any) map[string]any {
|
|
return map[string]any{"org_info": orgInfo}
|
|
}
|
|
|
|
t.Run("path key plans upload and strips every URL key for the mode", func(t *testing.T) {
|
|
os := orgSettings(map[string]any{
|
|
"org_logo_path_dark_mode": "logo.png",
|
|
"org_logo_url_dark_mode": "",
|
|
})
|
|
actions, err := c.planAndStripOrgLogos(os, &fleet.OrgInfo{}, dir, false, logFn)
|
|
require.NoError(t, err)
|
|
require.Len(t, actions, 1)
|
|
assert.Equal(t, fleet.OrgLogoModeDark, actions[0].mode)
|
|
assert.NotEmpty(t, actions[0].uploadPath)
|
|
|
|
orgInfo := os["org_info"].(map[string]any)
|
|
for _, k := range []string{"org_logo_path_dark_mode", "org_logo_url_dark_mode", "org_logo_url"} {
|
|
_, present := orgInfo[k]
|
|
assert.False(t, present, "%s should be stripped (PUT controls the stored URLs)", k)
|
|
}
|
|
})
|
|
|
|
t.Run("external URL with current Fleet-hosted blob plans delete and mirrors deprecated alias", func(t *testing.T) {
|
|
os := orgSettings(map[string]any{
|
|
"org_logo_url_dark_mode": "https://example.com/logo.png",
|
|
})
|
|
actions, err := c.planAndStripOrgLogos(os, &fleet.OrgInfo{
|
|
OrgLogoURLDarkMode: "https://fleet.example.com/api/latest/fleet/logo?mode=dark",
|
|
}, dir, false, logFn)
|
|
require.NoError(t, err)
|
|
require.Len(t, actions, 1)
|
|
assert.Equal(t, fleet.OrgLogoModeDark, actions[0].mode)
|
|
assert.Empty(t, actions[0].uploadPath, "empty uploadPath signals delete")
|
|
|
|
// URL key kept so PATCH writes the external URL, and the deprecated
|
|
// alias is mirrored so server-side NormalizeLogoFields can't undo it.
|
|
orgInfo := os["org_info"].(map[string]any)
|
|
assert.Equal(t, "https://example.com/logo.png", orgInfo["org_logo_url_dark_mode"])
|
|
assert.Equal(t, "https://example.com/logo.png", orgInfo["org_logo_url"])
|
|
})
|
|
|
|
t.Run("explicit empty URL with Fleet-hosted blob plans delete and mirrors deprecated alias as empty", func(t *testing.T) {
|
|
os := orgSettings(map[string]any{
|
|
"org_logo_url_light_mode": "",
|
|
})
|
|
actions, err := c.planAndStripOrgLogos(os, &fleet.OrgInfo{
|
|
OrgLogoURLLightMode: "/api/latest/fleet/logo?mode=light",
|
|
}, dir, false, logFn)
|
|
require.NoError(t, err)
|
|
require.Len(t, actions, 1)
|
|
assert.Equal(t, fleet.OrgLogoModeLight, actions[0].mode)
|
|
assert.Empty(t, actions[0].uploadPath)
|
|
|
|
// Both new and deprecated keys must be sent as "" — otherwise the
|
|
// server preserves the deprecated field on merge and copies it back
|
|
// into the new one in NormalizeLogoFields.
|
|
orgInfo := os["org_info"].(map[string]any)
|
|
assert.Empty(t, orgInfo["org_logo_url_light_mode"])
|
|
assert.Empty(t, orgInfo["org_logo_url_light_background"])
|
|
})
|
|
|
|
t.Run("clearing new URL keeps the deprecated alias in sync", func(t *testing.T) {
|
|
os := orgSettings(map[string]any{
|
|
"org_logo_url_dark_mode": "",
|
|
"org_logo_url_light_mode": "",
|
|
})
|
|
actions, err := c.planAndStripOrgLogos(os, &fleet.OrgInfo{
|
|
OrgLogoURLDarkMode: "https://customer.example.com/dark.png",
|
|
OrgLogoURL: "https://customer.example.com/dark.png",
|
|
OrgLogoURLLightMode: "https://customer.example.com/light.png",
|
|
OrgLogoURLLightBackground: "https://customer.example.com/light.png",
|
|
}, dir, false, logFn)
|
|
require.NoError(t, err)
|
|
// Current URLs aren't Fleet-hosted, so no DELETE actions queued.
|
|
assert.Empty(t, actions)
|
|
|
|
orgInfo := os["org_info"].(map[string]any)
|
|
assert.Empty(t, orgInfo["org_logo_url_dark_mode"])
|
|
assert.Empty(t, orgInfo["org_logo_url"], "deprecated dark alias must be sent as \"\"")
|
|
assert.Empty(t, orgInfo["org_logo_url_light_mode"])
|
|
assert.Empty(t, orgInfo["org_logo_url_light_background"], "deprecated light alias must be sent as \"\"")
|
|
})
|
|
|
|
t.Run("missing keys preserve current state", func(t *testing.T) {
|
|
os := orgSettings(map[string]any{"org_name": "ACME"})
|
|
actions, err := c.planAndStripOrgLogos(os, &fleet.OrgInfo{
|
|
OrgLogoURLDarkMode: "/api/latest/fleet/logo?mode=dark",
|
|
}, dir, false, logFn)
|
|
require.NoError(t, err)
|
|
assert.Empty(t, actions, "absent keys must not trigger any action")
|
|
})
|
|
|
|
t.Run("both path and url for same mode rejected", func(t *testing.T) {
|
|
os := orgSettings(map[string]any{
|
|
"org_logo_path_dark_mode": "logo.png",
|
|
"org_logo_url_dark_mode": "https://example.com/logo.png",
|
|
})
|
|
_, err := c.planAndStripOrgLogos(os, &fleet.OrgInfo{}, dir, false, logFn)
|
|
require.Error(t, err)
|
|
assert.ErrorContains(t, err, "cannot specify both")
|
|
})
|
|
|
|
t.Run("missing org_info is no-op", func(t *testing.T) {
|
|
actions, err := c.planAndStripOrgLogos(map[string]any{}, &fleet.OrgInfo{}, dir, false, logFn)
|
|
require.NoError(t, err)
|
|
assert.Empty(t, actions)
|
|
})
|
|
|
|
t.Run("both modes set are processed independently", func(t *testing.T) {
|
|
os := orgSettings(map[string]any{
|
|
"org_logo_path_dark_mode": "logo.png",
|
|
"org_logo_url_light_mode": "https://example.com/light.png",
|
|
})
|
|
actions, err := c.planAndStripOrgLogos(os, &fleet.OrgInfo{
|
|
OrgLogoURLLightMode: "/api/latest/fleet/logo?mode=light", // current is Fleet-hosted
|
|
}, dir, false, logFn)
|
|
require.NoError(t, err)
|
|
require.Len(t, actions, 2)
|
|
|
|
byMode := map[fleet.OrgLogoMode]orgLogoAction{}
|
|
for _, a := range actions {
|
|
byMode[a.mode] = a
|
|
}
|
|
// Dark: path → upload action.
|
|
darkAct, ok := byMode[fleet.OrgLogoModeDark]
|
|
require.True(t, ok)
|
|
assert.NotEmpty(t, darkAct.uploadPath, "dark mode should plan an upload")
|
|
// Light: external URL replacing a Fleet-hosted blob → delete action.
|
|
lightAct, ok := byMode[fleet.OrgLogoModeLight]
|
|
require.True(t, ok)
|
|
assert.Empty(t, lightAct.uploadPath, "light mode should plan a delete")
|
|
|
|
orgInfo := os["org_info"].(map[string]any)
|
|
// Dark: every URL key for the mode is stripped (PUT will set them).
|
|
for _, k := range []string{"org_logo_path_dark_mode", "org_logo_url_dark_mode", "org_logo_url"} {
|
|
_, present := orgInfo[k]
|
|
assert.False(t, present, "%s should be stripped", k)
|
|
}
|
|
// Light: URL key kept so PATCH writes the external URL, and the
|
|
// deprecated alias is mirrored to keep the server's
|
|
// NormalizeLogoFields a no-op.
|
|
assert.Equal(t, "https://example.com/light.png", orgInfo["org_logo_url_light_mode"])
|
|
assert.Equal(t, "https://example.com/light.png", orgInfo["org_logo_url_light_background"])
|
|
})
|
|
|
|
t.Run("missing path file surfaces a validation error", func(t *testing.T) {
|
|
os := orgSettings(map[string]any{
|
|
"org_logo_path_dark_mode": "does-not-exist.png",
|
|
})
|
|
_, err := c.planAndStripOrgLogos(os, &fleet.OrgInfo{}, dir, false, logFn)
|
|
require.Error(t, err)
|
|
require.ErrorContains(t, err, "dark")
|
|
require.ErrorContains(t, err, "does-not-exist.png")
|
|
})
|
|
|
|
t.Run("invalid file format surfaces a validation error", func(t *testing.T) {
|
|
badPath := filepath.Join(dir, "bad.png")
|
|
require.NoError(t, os.WriteFile(badPath, []byte("not an image"), 0o600))
|
|
settings := orgSettings(map[string]any{
|
|
"org_logo_path_dark_mode": "bad.png",
|
|
})
|
|
_, err := c.planAndStripOrgLogos(settings, &fleet.OrgInfo{}, dir, false, logFn)
|
|
require.Error(t, err)
|
|
assert.ErrorContains(t, err, "PNG, JPEG, or WebP")
|
|
})
|
|
|
|
t.Run("dry run still validates and logs would-upload", func(t *testing.T) {
|
|
var logs []string
|
|
captureLog := func(format string, args ...any) {
|
|
logs = append(logs, fmt.Sprintf(format, args...))
|
|
}
|
|
|
|
// Bad file should error in dry-run.
|
|
osBad := orgSettings(map[string]any{
|
|
"org_logo_path_dark_mode": "does-not-exist.png",
|
|
})
|
|
_, err := c.planAndStripOrgLogos(osBad, &fleet.OrgInfo{}, dir, true, captureLog)
|
|
require.Error(t, err)
|
|
|
|
// Valid file should plan an upload and log the would-upload line.
|
|
osGood := orgSettings(map[string]any{
|
|
"org_logo_path_dark_mode": "logo.png",
|
|
})
|
|
actions, err := c.planAndStripOrgLogos(osGood, &fleet.OrgInfo{}, dir, true, captureLog)
|
|
require.NoError(t, err)
|
|
require.Len(t, actions, 1)
|
|
require.NotEmpty(t, logs)
|
|
joined := strings.Join(logs, "\n")
|
|
assert.Contains(t, joined, "would upload org logo (dark)")
|
|
})
|
|
}
|