Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions changes/47290-long-fleet-name-ui
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
- Fixed long fleet names overflowing the fleets table, fleet detail page header, teams dropdown, and manage enroll secrets modal. Fleet name inputs now cap at 255 characters (matching the database column), and the API and GitOps now return a clear validation error instead of a raw "Data too long" MySQL error when a longer name is submitted.
- Added 255-character caps to additional user-supplied name and description inputs (API user, custom variable, certificate, label, pack, certificate authority forms) to prevent the same class of overflow bug.
- Fixed long label names overflowing the Labels card on the host details page.
10 changes: 10 additions & 0 deletions ee/server/service/teams.go
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import (
"net/http"
"net/url"
"strings"
"unicode/utf8"

"golang.org/x/text/unicode/norm"

Expand Down Expand Up @@ -96,6 +97,9 @@ func (svc *Service) NewTeam(ctx context.Context, p fleet.TeamPayload) (*fleet.Te
if *p.Name == "" {
return nil, fleet.NewInvalidArgumentError("name", "may not be empty")
}
if utf8.RuneCountInString(*p.Name) > fleet.MaxTeamNameLength {
return nil, fleet.NewInvalidArgumentError("name", fmt.Sprintf("may not exceed %d characters", fleet.MaxTeamNameLength))
}
if fleet.IsReservedTeamName(*p.Name) {
return nil, fleet.NewInvalidArgumentError("name", fmt.Sprintf("%q is a reserved fleet name", *p.Name))
}
Expand Down Expand Up @@ -184,6 +188,9 @@ func (svc *Service) ModifyTeam(ctx context.Context, teamID uint, payload fleet.T
if *payload.Name == "" {
return nil, fleet.NewInvalidArgumentError("name", "may not be empty")
}
if utf8.RuneCountInString(*payload.Name) > fleet.MaxTeamNameLength {
return nil, fleet.NewInvalidArgumentError("name", fmt.Sprintf("may not exceed %d characters", fleet.MaxTeamNameLength))
}
if fleet.IsReservedTeamName(*payload.Name) {
return nil, fleet.NewInvalidArgumentError("name", fmt.Sprintf("%q is a reserved fleet name", *payload.Name))
}
Expand Down Expand Up @@ -1326,6 +1333,9 @@ func (svc *Service) ApplyTeamSpecs(ctx context.Context, specs []*fleet.TeamSpec,
if spec.Name == "" {
return nil, fleet.NewInvalidArgumentError("name", "name may not be empty")
}
if utf8.RuneCountInString(spec.Name) > fleet.MaxTeamNameLength {
return nil, fleet.NewInvalidArgumentError("name", fmt.Sprintf("may not exceed %d characters", fleet.MaxTeamNameLength))
}
if fleet.IsReservedTeamName(spec.Name) {
return nil, fleet.NewInvalidArgumentError("name", fmt.Sprintf("%q is a reserved fleet name", spec.Name))
}
Expand Down
48 changes: 48 additions & 0 deletions ee/server/service/teams_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ package service

import (
"context"
"fmt"
"log/slog"
"strings"
"testing"
Expand Down Expand Up @@ -134,6 +135,23 @@ func TestNewTeamNameValidation(t *testing.T) {
teamName: ptr.String("Engineering"),
wantName: "Engineering",
},
{
name: "name at max length is accepted",
teamName: new(strings.Repeat("a", fleet.MaxTeamNameLength)),

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

False positive, I think — Go 1.26 supports new(expression), and .claude/rules/fleet-go-backend.md explicitly steers us toward new("value") / new(true) / new(42) over ptr.String(...). Tests compile and pass locally (go test -run TestNewTeamNameValidation ./ee/server/service/...).

I'm not a strong Go reviewer myself — can someone confirm new(strings.Repeat(...)) (function-call expression, not a literal) is fine per current convention, or should I swap to ptr.String(...) for readability?

wantName: strings.Repeat("a", fleet.MaxTeamNameLength),
},
{
name: "name over max length is rejected",
teamName: new(strings.Repeat("a", fleet.MaxTeamNameLength+1)),

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

False positive, I think — Go 1.26 supports new(expression), and .claude/rules/fleet-go-backend.md explicitly steers us toward new("value") / new(true) / new(42) over ptr.String(...). Tests compile and pass locally (go test -run TestNewTeamNameValidation ./ee/server/service/...).

I'm not a strong Go reviewer myself — can someone confirm new(strings.Repeat(...)) (function-call expression, not a literal) is fine per current convention, or should I swap to ptr.String(...) for readability?

wantErr: fmt.Sprintf("may not exceed %d characters", fleet.MaxTeamNameLength),
},
{
// Guards against regressing to byte-based length checks, which
// would reject multibyte names that fit within the character cap.
name: "multibyte name at max character length is accepted",
teamName: new(strings.Repeat("日", fleet.MaxTeamNameLength)),

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

False positive, I think — Go 1.26 supports new(expression), and .claude/rules/fleet-go-backend.md explicitly steers us toward new("value") / new(true) / new(42) over ptr.String(...). Tests compile and pass locally (go test -run TestNewTeamNameValidation ./ee/server/service/...).

I'm not a strong Go reviewer myself — can someone confirm new(strings.Repeat(...)) (function-call expression, not a literal) is fine per current convention, or should I swap to ptr.String(...) for readability?

wantName: strings.Repeat("日", fleet.MaxTeamNameLength),
},
}

for _, tc := range testCases {
Expand Down Expand Up @@ -257,6 +275,21 @@ func TestModifyTeamNameValidation(t *testing.T) {
teamName: ptr.String("my team"),
wantName: "my team",
},
{
name: "name at max length is accepted",
teamName: new(strings.Repeat("a", fleet.MaxTeamNameLength)),

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

False positive, I think — Go 1.26 supports new(expression), and .claude/rules/fleet-go-backend.md explicitly steers us toward new("value") / new(true) / new(42) over ptr.String(...). Tests compile and pass locally (go test -run TestNewTeamNameValidation ./ee/server/service/...).

I'm not a strong Go reviewer myself — can someone confirm new(strings.Repeat(...)) (function-call expression, not a literal) is fine per current convention, or should I swap to ptr.String(...) for readability?

wantName: strings.Repeat("a", fleet.MaxTeamNameLength),
},
{
name: "name over max length is rejected",
teamName: new(strings.Repeat("a", fleet.MaxTeamNameLength+1)),

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

False positive, I think — Go 1.26 supports new(expression), and .claude/rules/fleet-go-backend.md explicitly steers us toward new("value") / new(true) / new(42) over ptr.String(...). Tests compile and pass locally (go test -run TestNewTeamNameValidation ./ee/server/service/...).

I'm not a strong Go reviewer myself — can someone confirm new(strings.Repeat(...)) (function-call expression, not a literal) is fine per current convention, or should I swap to ptr.String(...) for readability?

wantErr: fmt.Sprintf("may not exceed %d characters", fleet.MaxTeamNameLength),
},
{
name: "multibyte name at max character length is accepted",
teamName: new(strings.Repeat("日", fleet.MaxTeamNameLength)),

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

False positive, I think — Go 1.26 supports new(expression), and .claude/rules/fleet-go-backend.md explicitly steers us toward new("value") / new(true) / new(42) over ptr.String(...). Tests compile and pass locally (go test -run TestNewTeamNameValidation ./ee/server/service/...).

I'm not a strong Go reviewer myself — can someone confirm new(strings.Repeat(...)) (function-call expression, not a literal) is fine per current convention, or should I swap to ptr.String(...) for readability?

wantName: strings.Repeat("日", fleet.MaxTeamNameLength),
},
}

for _, tc := range testCases {
Expand Down Expand Up @@ -367,6 +400,21 @@ func TestApplyTeamSpecsNameValidation(t *testing.T) {
teamName: " Engineering ",
wantName: "Engineering",
},
{
name: "name at max length is accepted",
teamName: strings.Repeat("a", fleet.MaxTeamNameLength),
wantName: strings.Repeat("a", fleet.MaxTeamNameLength),
},
{
name: "name over max length is rejected",
teamName: strings.Repeat("a", fleet.MaxTeamNameLength+1),
wantErr: fmt.Sprintf("may not exceed %d characters", fleet.MaxTeamNameLength),
},
{
name: "multibyte name at max character length is accepted",
teamName: strings.Repeat("日", fleet.MaxTeamNameLength),
wantName: strings.Repeat("日", fleet.MaxTeamNameLength),
},
}

for _, tc := range testCases {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,11 @@
color: $ui-error;
}

&__description {
min-width: 0;
overflow-wrap: anywhere;
}

.empty-table__container {
margin: $pad-large auto $pad-medium;
gap: $pad-medium;
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import React, { useState } from "react";
import useDeepEffect from "hooks/useDeepEffect";

import { MAX_ENTITY_CHAR_LENGTH } from "utilities/constants";
import Button from "components/buttons/Button";

import { IQuery } from "interfaces/query";
Expand Down Expand Up @@ -111,6 +112,7 @@ const EditPackForm = ({
name="name"
error={errors.name}
inputWrapperClass={`${baseClass}__pack-title`}
inputOptions={{ maxLength: MAX_ENTITY_CHAR_LENGTH }}
/>
<InputField
onChange={onChangePackDescription}
Expand All @@ -120,6 +122,7 @@ const EditPackForm = ({
name="description"
placeholder="Add a description of your pack"
type="textarea"
inputOptions={{ maxLength: MAX_ENTITY_CHAR_LENGTH }}
/>
<SelectTargetsDropdown
label="Select pack targets"
Expand Down
3 changes: 3 additions & 0 deletions frontend/components/forms/packs/NewPackForm/NewPackForm.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import { IQuery } from "interfaces/query";
import { ITarget, ITargetsAPIResponse } from "interfaces/target";
import { IEditPackFormData } from "interfaces/pack";
import PATHS from "router/paths";
import { MAX_ENTITY_CHAR_LENGTH } from "utilities/constants";

import InputField from "components/forms/fields/InputField";
import BackButton from "components/BackButton";
Expand Down Expand Up @@ -93,6 +94,7 @@ const NewPackForm = ({
error={errors.name}
inputWrapperClass={`${baseClass}__pack-title`}
autofocus
inputOptions={{ maxLength: MAX_ENTITY_CHAR_LENGTH }}
/>
<InputField
onChange={onChangePackDescription}
Expand All @@ -102,6 +104,7 @@ const NewPackForm = ({
name="description"
placeholder="Add a description of your pack"
type="textarea"
inputOptions={{ maxLength: MAX_ENTITY_CHAR_LENGTH }}
/>
<SelectTargetsDropdown
label="Select pack targets"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,6 @@ import {
CA_REQUIRED_MSG,
INVALID_NAME_MSG,
NAME_REQUIRED_MSG,
NAME_TOO_LONG_MSG,
SUBJECT_NAME_REQUIRED_MSG,
} from "./helpers";

Expand Down Expand Up @@ -174,16 +173,13 @@ describe("AddCertModal", () => {
});
});

it("shows inline error for Name longer than 255 characters as user types", async () => {
const { user } = await renderModal();

// Paste rather than type to keep the test fast (256 simulated keypresses is slow).
await user.click(screen.getByPlaceholderText(NAME_PLACEHOLDER));
await user.paste("a".repeat(256));
it("caps the Name input at 255 characters (matches DB varchar(255))", async () => {
await renderModal();

await waitFor(() => {
expect(screen.getByText(NAME_TOO_LONG_MSG)).toBeInTheDocument();
});
const nameInput = screen.getByPlaceholderText(
NAME_PLACEHOLDER
) as HTMLInputElement;
expect(nameInput.maxLength).toBe(255);
});

it("submits successfully without SAN (field omitted from request body)", async () => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,10 @@ import React, { useMemo, useState } from "react";
import { useQuery } from "react-query";
import { SingleValue } from "react-select-5";

import { DEFAULT_USE_QUERY_OPTIONS } from "utilities/constants";
import {
DEFAULT_USE_QUERY_OPTIONS,
MAX_ENTITY_CHAR_LENGTH,
} from "utilities/constants";

import paths from "router/paths";

Expand Down Expand Up @@ -203,6 +206,7 @@ const AddCertModal = ({
helpText="Letters, numbers, spaces, dashes, and underscores only. Name can be used as certificate alias to reference in configuration profiles."
parseTarget
placeholder="VPN certificate"
inputOptions={{ maxLength: MAX_ENTITY_CHAR_LENGTH }}
/>
<InputField
name="subjectName"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,6 @@ export interface IAddCertFormValidation {

export const INVALID_NAME_MSG =
"Invalid characters. Only letters, numbers, spaces, dashes, and underscores allowed.";
export const NAME_TOO_LONG_MSG = "Name is too long. Maximum is 255 characters.";
export const NAME_REQUIRED_MSG = "Name must be completed.";
export const CA_REQUIRED_MSG = "Certificate authority must be completed.";
export const SUBJECT_NAME_REQUIRED_MSG = "Subject name must be completed.";
Expand Down Expand Up @@ -52,13 +51,6 @@ export const generateFormValidations = (): IFormValidations => {
},
message: INVALID_NAME_MSG,
},
{
name: "maxLength",
isValid: (formData: IAddCertFormData) => {
return formData.name.length <= 255;
},
message: NAME_TOO_LONG_MSG,
},
],
},
certAuthorityId: {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -315,17 +315,9 @@ describe("Custom variables", () => {
expect(saveButton).toBeDisabled();
});
});
it("does not allow saving very long name", async () => {
const { nameInput, valueInput, saveButton } = await getAddVariableUI();
await user.type(nameInput, new Array(256).fill("A").join("")); // Invalid name
await user.type(valueInput, "a value");
await user.click(saveButton);
await waitFor(() => {
expect(
screen.getByText("Name may not exceed 255 characters")
).toBeInTheDocument();
expect(saveButton).toBeDisabled();
});
it("caps the name input at 255 characters (matches DB varchar(255))", async () => {
const { nameInput } = await getAddVariableUI();
expect((nameInput as HTMLInputElement).maxLength).toBe(255);
});
});

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,10 @@ import Button from "components/buttons/Button";
import { IVariableFormData } from "interfaces/variables";
import { hasStatusKey, getErrorReason } from "interfaces/errors";
import variablesAPI from "services/entities/variables";
import { LEARN_MORE_ABOUT_BASE_LINK } from "utilities/constants";
import {
LEARN_MORE_ABOUT_BASE_LINK,
MAX_ENTITY_CHAR_LENGTH,
} from "utilities/constants";
import { notify } from "components/ToastNotification";
import CustomLink from "components/CustomLink";
import InputField from "components/forms/fields/InputField";
Expand Down Expand Up @@ -117,6 +120,7 @@ const AddCustomVariableModal = ({
</span>
}
error={formValidation.name?.message}
inputOptions={{ maxLength: MAX_ENTITY_CHAR_LENGTH }}
/>
<InputField
onChange={onInputChange}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -47,13 +47,6 @@ const FORM_VALIDATIONS: IFormValidations = {
message:
"Name may only include uppercase letters, numbers, and underscores",
},
{
name: "notTooLong",
isValid: (formData: IAddCustomVariableFormData) => {
return formData.name.length <= 255;
},
message: "Name may not exceed 255 characters",
},
{
name: "doesNotIncludePrefix",
isValid: (formData: IAddCustomVariableFormData) => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,13 +2,13 @@ import React, { useState } from "react";

import selfServiceCategoriesAPI from "services/entities/self_service_categories";
import { hasStatusKey } from "interfaces/errors";
import { MAX_ENTITY_CHAR_LENGTH } from "utilities/constants";

import Button from "components/buttons/Button";
import InputField from "components/forms/fields/InputField";
import Modal from "components/Modal";

const baseClass = "add-category-modal";
const NAME_MAX_LENGTH = 255;

interface IAddCategoryModalProps {
fleetId: number;
Expand All @@ -26,9 +26,7 @@ const AddCategoryModal = ({
const [isSubmitting, setIsSubmitting] = useState(false);

const trimmedName = name.trim();
const isInvalid =
trimmedName.length === 0 || trimmedName.length > NAME_MAX_LENGTH;
const isDisabled = isInvalid || isSubmitting;
const isDisabled = trimmedName.length === 0 || isSubmitting;

const onNameChange = (value: string) => {
setName(value);
Expand Down Expand Up @@ -74,7 +72,7 @@ const AddCategoryModal = ({
error={error}
autofocus
ignore1password
inputOptions={{ maxLength: NAME_MAX_LENGTH }}
inputOptions={{ maxLength: MAX_ENTITY_CHAR_LENGTH }}
/>
<div className="modal-cta-wrap">
<Button type="submit" disabled={isDisabled} isLoading={isSubmitting}>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,13 +3,13 @@ import React, { useState } from "react";
import selfServiceCategoriesAPI from "services/entities/self_service_categories";
import { hasStatusKey } from "interfaces/errors";
import { ISelfServiceCategory } from "interfaces/self_service_category";
import { MAX_ENTITY_CHAR_LENGTH } from "utilities/constants";

import Button from "components/buttons/Button";
import InputField from "components/forms/fields/InputField";
import Modal from "components/Modal";

const baseClass = "edit-category-modal";
const NAME_MAX_LENGTH = 255;

interface IEditCategoryModalProps {
category: ISelfServiceCategory;
Expand All @@ -27,10 +27,8 @@ const EditCategoryModal = ({
const [isSubmitting, setIsSubmitting] = useState(false);

const trimmedName = name.trim();
const isInvalid =
trimmedName.length === 0 || trimmedName.length > NAME_MAX_LENGTH;
const isUnchanged = trimmedName === category.name;
const isDisabled = isInvalid || isUnchanged || isSubmitting;
const isDisabled = trimmedName.length === 0 || isUnchanged || isSubmitting;

const onNameChange = (value: string) => {
setName(value);
Expand Down Expand Up @@ -75,7 +73,7 @@ const EditCategoryModal = ({
error={error}
autofocus
ignore1password
inputOptions={{ maxLength: NAME_MAX_LENGTH }}
inputOptions={{ maxLength: MAX_ENTITY_CHAR_LENGTH }}
/>
<div className="modal-cta-wrap">
<Button type="submit" disabled={isDisabled} isLoading={isSubmitting}>
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import React, { useMemo } from "react";

import { ICertificateAuthorityPartial } from "interfaces/certificates";
import { MAX_ENTITY_CHAR_LENGTH } from "utilities/constants";

import InputField from "components/forms/fields/InputField";
import Button from "components/buttons/Button";
Expand Down Expand Up @@ -63,6 +64,7 @@ const CustomESTForm = ({
parseTarget
placeholder="WIFI_CERTIFICATE"
helpText="Letters, numbers, and underscores only."
inputOptions={{ maxLength: MAX_ENTITY_CHAR_LENGTH }}
/>
<InputField
label="URL"
Expand Down
Loading
Loading