Skip to content
Merged
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
5 changes: 5 additions & 0 deletions .changeset/channel-name-validation.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@bigcommerce/catalyst": patch
---

Validate channel names before creating a channel. `catalyst create` and `catalyst channel create` now reject names containing unsupported characters (such as an apostrophe in "Bob's Store") with a clear message that names the offending input and lists the allowed characters — letters, numbers, spaces, hyphens, and underscores — instead of surfacing an opaque API error. The interactive prompt validates as you type, and an invalid `--name` flag fails fast.
137 changes: 137 additions & 0 deletions packages/catalyst/src/cli/lib/create-channel-flow.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,137 @@
import { checkbox, input, select } from '@inquirer/prompts';
import { afterEach, describe, expect, test, vi } from 'vitest';

import { createChannel } from './channels';
import { getChannelNameError, runCreateChannelFlow } from './create-channel-flow';
import { UserActionableError } from './errors';
import { getAvailableLocales } from './localization';

vi.mock('@inquirer/prompts', () => ({
input: vi.fn(),
select: vi.fn(),
checkbox: vi.fn(),
}));

vi.mock('./channels', () => ({
createChannel: vi.fn(),
}));

vi.mock('./localization', () => ({
getAvailableLocales: vi.fn(),
}));

const mockInput = vi.mocked(input);
const mockSelect = vi.mocked(select);
const mockCheckbox = vi.mocked(checkbox);
const mockCreateChannel = vi.mocked(createChannel);
const mockGetAvailableLocales = vi.mocked(getAvailableLocales);

const baseOptions = {
storeHash: 'test-store',
accessToken: 'test-token',
apiHost: 'api.bigcommerce.com',
cliApiOrigin: 'https://cxm-prd.bigcommerceapp.com',
};

afterEach(() => {
vi.clearAllMocks();
});

describe('getChannelNameError', () => {
test.each([
['My Store', 'a plain name'],
['My-Store_2', 'hyphens and underscores'],
['Café Münchën', 'accented / non-ASCII letters'],
[' Padded ', 'surrounding whitespace'],
])('accepts %j (%s)', (name) => {
expect(getChannelNameError(name)).toBeUndefined();
});

test.each([
["Bob's Store", 'an apostrophe'],
['Store & Co', 'an ampersand'],
['Store #1', 'a hash'],
['Store (US)', 'parentheses'],
])('rejects %j (%s) with an actionable message', (name) => {
const error = getChannelNameError(name);

expect(error).toContain(name);
expect(error).toContain('not a valid channel name');
expect(error).toContain('letters, numbers, spaces, hyphens (-), and underscores (_)');
});

test.each([
['', 'an empty string'],
[' ', 'only whitespace'],
])('rejects %j (%s) as empty', (name) => {
expect(getChannelNameError(name)).toBe('Channel name cannot be empty.');
});
});

describe('runCreateChannelFlow', () => {
test('throws UserActionableError for an invalid --name before calling the API', async () => {
await expect(runCreateChannelFlow({ ...baseOptions, name: "Bob's Store" })).rejects.toThrow(
UserActionableError,
);

await expect(runCreateChannelFlow({ ...baseOptions, name: "Bob's Store" })).rejects.toThrow(
/not a valid channel name/,
);

expect(mockCreateChannel).not.toHaveBeenCalled();
expect(mockInput).not.toHaveBeenCalled();
});

test('validates the interactive name prompt with getChannelNameError', async () => {
mockInput.mockResolvedValue('My Store');
mockSelect
.mockResolvedValueOnce('en') // default locale
.mockResolvedValueOnce(false) // add additional languages?
.mockResolvedValueOnce(false); // install sample data?
mockGetAvailableLocales.mockResolvedValue([{ name: 'English', value: 'en' }]);
mockCreateChannel.mockResolvedValue({
channelId: 42,
storefrontToken: 'token',
envVars: {},
});

await runCreateChannelFlow(baseOptions);

const validate = mockInput.mock.calls[0]?.[0]?.validate;

expect(validate).toBeTypeOf('function');
expect(validate?.("Bob's Store")).toBe(
'"Bob\'s Store" is not a valid channel name. Channel names may contain only letters, numbers, spaces, hyphens (-), and underscores (_).',
);
expect(validate?.('My Store')).toBe(true);
});

test('passes a valid --name straight through to createChannel', async () => {
mockCreateChannel.mockResolvedValue({
channelId: 42,
storefrontToken: 'token',
envVars: {},
});

await runCreateChannelFlow({
...baseOptions,
name: 'My Store',
locale: 'en',
additionalLocales: [],
sampleData: false,
});

expect(mockInput).not.toHaveBeenCalled();
expect(mockSelect).not.toHaveBeenCalled();
expect(mockCheckbox).not.toHaveBeenCalled();
expect(mockCreateChannel).toHaveBeenCalledWith(
'My Store',
'en',
[],
false,
baseOptions.storeHash,
baseOptions.accessToken,
baseOptions.cliApiOrigin,
);
});
});
36 changes: 36 additions & 0 deletions packages/catalyst/src/cli/lib/create-channel-flow.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,33 @@ import { checkbox, input, select } from '@inquirer/prompts';
import { colorize } from 'consola/utils';

import { createChannel, type CreatedChannel } from './channels';
import { UserActionableError } from './errors';
import { getAvailableLocales } from './localization';

// Human-readable summary of the allowed characters, reused in the prompt
// validation and the flag-path error so both surface the same guidance.
const ALLOWED_CHANNEL_NAME_CHARS = 'letters, numbers, spaces, hyphens (-), and underscores (_)';
Comment thread
mfaris9 marked this conversation as resolved.

// The Catalyst channels API rejects names containing other punctuation (e.g. an
// apostrophe in "Bob's Store") with an opaque server error. Validate up front so
// the user gets an immediate, actionable message instead. Letters/numbers are
// matched with Unicode classes so non-ASCII names (accents, other scripts) pass.
const CHANNEL_NAME_PATTERN = /^[\p{L}\p{N} _-]+$/u;

// Returns an error message when `name` is not a valid channel name, or
// `undefined` when it is. Shared by the interactive prompt and the `--name` flag.
export function getChannelNameError(name: string): string | undefined {
if (name.trim().length === 0) {
return 'Channel name cannot be empty.';
}

if (!CHANNEL_NAME_PATTERN.test(name)) {
return `"${name}" is not a valid channel name. Channel names may contain only ${ALLOWED_CHANNEL_NAME_CHARS}.`;
}

return undefined;
}

export interface CreateChannelFlowOptions {
storeHash: string;
accessToken: string;
Expand All @@ -25,10 +50,21 @@ export async function runCreateChannelFlow(
): Promise<CreatedChannel> {
const { storeHash, accessToken, apiHost, cliApiOrigin } = options;

// A `--name` flag skips the prompt (and its validation), so check it here to
// fail fast with the same clear message rather than an opaque API rejection.
if (options.name !== undefined) {
const nameError = getChannelNameError(options.name);

if (nameError) {
throw new UserActionableError(nameError);
}
}

const name =
options.name ??
(await input({
message: 'What would you like to name your new channel?',
validate: (value) => getChannelNameError(value) ?? true,
}));

// The locale list backs both the default-locale and additional-locales
Expand Down
Loading