Files
caprover/tests/OneClickAppDeployManager.test.ts
cevheri be9192c5f5 fix: check one-click app names before creating anything
A one-click deploy with more than one service checked each app name only
when it registered that app. A later name that was too long or already
taken failed after the project and the earlier apps were created, and
those stayed behind. Check every name before the first step instead, and
say the length limit in the error.

Fixes #2504
2026-10-05 12:53:04 +03:00

151 lines
5.6 KiB
TypeScript

import AppsDataStore from '../src/datastore/AppsDataStore'
import OneClickAppDeploymentHelper from '../src/user/oneclick/OneClickAppDeploymentHelper'
import OneClickAppDeployManager, {
ONE_CLICK_APP_NAME_VAR_NAME,
} from '../src/user/oneclick/OneClickAppDeployManager'
import { IOneClickTemplate } from '../src/models/IOneClickAppModels'
describe('OneClickAppDeployManager', () => {
test('treats omitted values as an empty array', () => {
const onDeploymentStateChanged = jest.fn()
const manager = new OneClickAppDeployManager(
{} as any,
{} as any,
onDeploymentStateChanged
)
const template: IOneClickTemplate = {
captainVersion: 4,
services: {},
caproverOneClickApp: {
displayName: 'Test App',
instructions: {
start: '',
end: '',
},
variables: [
{
id: ONE_CLICK_APP_NAME_VAR_NAME,
label: 'App Name',
},
],
},
}
expect(() => manager.startDeployProcess(template)).not.toThrow()
expect(onDeploymentStateChanged).toHaveBeenCalledWith({
steps: ['Parsing the template'],
error: 'App name ($$cap_appname) is required.',
currentStep: 0,
})
})
describe('checks every app name before it creates anything', () => {
const template: IOneClickTemplate = {
captainVersion: 4,
services: {
[ONE_CLICK_APP_NAME_VAR_NAME]: { image: 'nginx' },
[`${ONE_CLICK_APP_NAME_VAR_NAME}-worker`]: { image: 'nginx' },
},
caproverOneClickApp: {
displayName: 'Test App',
instructions: { start: '', end: 'done' },
variables: [
{ id: ONE_CLICK_APP_NAME_VAR_NAME, label: 'App Name' },
],
},
}
// The steps that create something; each one only records its call.
const helper = OneClickAppDeploymentHelper.prototype
let creates: jest.SpyInstance[]
beforeEach(() => {
creates = [
'createRegisterPromiseProject',
'createRegisterPromise',
'createConfigurationPromise',
'createDeploymentPromise',
].map((step) =>
jest.spyOn(helper, step as any).mockResolvedValue(undefined)
)
})
afterEach(() => jest.restoreAllMocks())
// Returns every state the deploy reported, after pending steps ran.
async function deploy(appName: string, existingApps: string[] = []) {
const onDeploymentStateChanged = jest.fn()
const appsDataStore = new AppsDataStore(
{
get: (key: string) =>
existingApps.includes(key.split('.')[1])
? {}
: undefined,
} as any,
'captain'
)
new OneClickAppDeployManager(
{ getAppsDataStore: () => appsDataStore } as any,
{} as any,
onDeploymentStateChanged
).startDeployProcess(template, [
{ key: ONE_CLICK_APP_NAME_VAR_NAME, value: appName },
])
await new Promise((resolve) => setImmediate(resolve))
return onDeploymentStateChanged.mock.calls.map((call) => call[0])
}
test.each([
[
'a later app name that is too long',
'a'.repeat(43),
[],
`${'a'.repeat(43)}-worker: App Name is not allowed. Only lowercase letters, numbers and single hyphens are allowed, up to 49 characters`,
],
[
'a later app name that already exists',
'my-app',
['my-app-worker'],
'my-app-worker: App Name already exists. Please use a different name',
],
[
'the first app name that already exists',
'my-app',
['my-app'],
'my-app: App Name already exists. Please use a different name',
],
])('%s', async (_case, appName, existingApps, error) => {
expect(await deploy(appName, existingApps)).toEqual([
{ steps: ['Parsing the template'], error, currentStep: 0 },
])
creates.forEach((create) => expect(create).not.toHaveBeenCalled())
})
test('names that pass are deployed as before', async () => {
const states = await deploy('my-app')
expect(states[states.length - 1]).toMatchObject({
error: '',
currentStep: 8,
successMessage: 'done',
})
expect(creates[1].mock.calls.map((call) => call[0])).toEqual([
'my-app',
'my-app-worker',
])
})
test('an error that is not about a name is thrown, not reported as one', () => {
const manager = new OneClickAppDeployManager(
{} as any,
{} as any,
jest.fn()
)
expect(() =>
manager.startDeployProcess(template, [
{ key: ONE_CLICK_APP_NAME_VAR_NAME, value: 'my-app' },
])
).toThrow(TypeError)
creates.forEach((create) => expect(create).not.toHaveBeenCalled())
})
})
})