Files
fleet/server/service/client_appconfig_test.go
T
Nico f2b2e23b0a GitOps changes for custom org's logo uploads (#44550)
<!-- 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 -->
2026-05-05 18:18:08 +02:00

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