mirror of
https://github.com/caprover/caprover
synced 2026-05-03 18:20:32 +00:00
38a45dfbe7
The existing POST /update/ replaces all fields — omitted fields are
reset to defaults (e.g. envVars becomes [], instanceCount becomes 0).
This makes simple operations like scaling dangerous: sending only
{appName, instanceCount} wipes all environment variables.
The new PATCH /update/ endpoint fetches the existing app definition
and merges only the explicitly provided fields. Omitted fields retain
their current values.
Example — scale without touching env vars:
PATCH /api/v2/user/apps/appDefinitions/update/
{"appName": "my-app", "instanceCount": 1}
The POST endpoint is unchanged — full backward compatibility.
242 lines
7.9 KiB
TypeScript
242 lines
7.9 KiB
TypeScript
/**
|
|
* TEST FILE: PatchAppDefinition.test.ts
|
|
*
|
|
* Tests the `patchAppDefinition` handler which partially updates an app
|
|
* definition by merging only the provided fields with existing values.
|
|
*
|
|
* This ensures that operations like scaling (changing instanceCount) do not
|
|
* accidentally wipe env vars, volumes, ports, or other app configuration.
|
|
*/
|
|
|
|
import { patchAppDefinition } from '../src/handlers/users/apps/appdefinition/AppDefinitionHandler'
|
|
import { IAppDef } from '../src/models/AppDefinition'
|
|
|
|
const mockExistingApp: IAppDef = {
|
|
description: 'My test app',
|
|
deployedVersion: 5,
|
|
notExposeAsWebApp: false,
|
|
hasPersistentData: false,
|
|
hasDefaultSubDomainSsl: true,
|
|
containerHttpPort: 3000,
|
|
captainDefinitionRelativeFilePath: './captain-definition',
|
|
forceSsl: true,
|
|
websocketSupport: true,
|
|
nodeId: 'node-abc',
|
|
instanceCount: 2,
|
|
preDeployFunction: 'console.log("pre")',
|
|
serviceUpdateOverride: '',
|
|
customNginxConfig: '',
|
|
redirectDomain: '',
|
|
networks: ['captain-overlay-network'],
|
|
customDomain: [],
|
|
tags: [{ tagName: 'production' }],
|
|
ports: [{ containerPort: 3000, hostPort: 3000, protocol: 'tcp' }],
|
|
volumes: [
|
|
{
|
|
containerPath: '/data',
|
|
volumeName: 'app-data',
|
|
},
|
|
],
|
|
envVars: [
|
|
{ key: 'API_HOST', value: 'https://api.example.com' },
|
|
{ key: 'API_KEY', value: 'secret-key-123' },
|
|
{ key: 'DB_URL', value: 'postgres://localhost/mydb' },
|
|
],
|
|
versions: [],
|
|
appDeployTokenConfig: { enabled: true, appDeployToken: 'tok-123' },
|
|
appPushWebhook: {
|
|
tokenVersion: 'v1',
|
|
repoInfo: {
|
|
repo: 'my-repo',
|
|
branch: 'main',
|
|
user: 'my-user',
|
|
password: 'my-pass',
|
|
},
|
|
pushWebhookToken: 'webhook-tok',
|
|
},
|
|
httpAuth: { user: 'admin', password: 'pass123' },
|
|
}
|
|
|
|
// Track what updateAppDefinition receives via the serviceManager mock
|
|
let capturedUpdateArgs: any[] = []
|
|
|
|
const mockDataStore = {
|
|
getAppsDataStore: () => ({
|
|
getAppDefinition: jest.fn().mockResolvedValue(mockExistingApp),
|
|
}),
|
|
} as any
|
|
|
|
const mockServiceManager = {
|
|
updateAppDefinition: jest.fn().mockImplementation((...args: any[]) => {
|
|
capturedUpdateArgs = args
|
|
return Promise.resolve()
|
|
}),
|
|
ensureNotBuilding: jest.fn().mockResolvedValue(undefined),
|
|
dataStore: mockDataStore,
|
|
} as any
|
|
|
|
describe('patchAppDefinition', () => {
|
|
beforeEach(() => {
|
|
jest.clearAllMocks()
|
|
capturedUpdateArgs = []
|
|
})
|
|
|
|
it('should preserve all existing fields when only instanceCount is provided', async () => {
|
|
await patchAppDefinition(
|
|
'my-app',
|
|
{ appName: 'my-app', instanceCount: 1 },
|
|
mockDataStore,
|
|
mockServiceManager
|
|
)
|
|
|
|
expect(mockServiceManager.updateAppDefinition).toHaveBeenCalledTimes(1)
|
|
|
|
// serviceManager.updateAppDefinition positional args:
|
|
// 0=appName, 1=projectId, 2=description, 3=instanceCount, 4=captainDefPath,
|
|
// 5=envVars, 6=volumes, 7=tags, 8=nodeId, 9=notExposeAsWebApp,
|
|
// 10=containerHttpPort, 11=httpAuth, 12=forceSsl, 13=ports,
|
|
// 14=repoInfo, 15=customNginxConfig, 16=redirectDomain,
|
|
// 17=preDeployFunction, 18=serviceUpdateOverride, 19=websocketSupport,
|
|
// 20=appDeployTokenConfig
|
|
const args = capturedUpdateArgs
|
|
expect(args[0]).toBe('my-app') // appName
|
|
expect(args[3]).toBe(1) // instanceCount — patched
|
|
|
|
// envVars (arg 5) should be preserved
|
|
expect(args[5]).toEqual(mockExistingApp.envVars)
|
|
expect(args[5]).toHaveLength(3)
|
|
|
|
// volumes (arg 6) preserved
|
|
expect(args[6]).toEqual(mockExistingApp.volumes)
|
|
|
|
// tags (arg 7) preserved
|
|
expect(args[7]).toEqual(mockExistingApp.tags)
|
|
|
|
// forceSsl (arg 12) preserved
|
|
expect(args[12]).toBe(true)
|
|
|
|
// websocketSupport (arg 19) preserved
|
|
expect(args[19]).toBe(true)
|
|
})
|
|
|
|
it('should preserve env vars when scaling to zero', async () => {
|
|
await patchAppDefinition(
|
|
'my-app',
|
|
{ appName: 'my-app', instanceCount: 0 },
|
|
mockDataStore,
|
|
mockServiceManager
|
|
)
|
|
|
|
const args = capturedUpdateArgs
|
|
expect(args[3]).toBe(0) // instanceCount
|
|
expect(args[5]).toEqual(mockExistingApp.envVars) // envVars preserved
|
|
expect(args[5]).toHaveLength(3)
|
|
})
|
|
|
|
it('should update only the provided fields', async () => {
|
|
await patchAppDefinition(
|
|
'my-app',
|
|
{
|
|
appName: 'my-app',
|
|
instanceCount: 3,
|
|
description: 'Updated description',
|
|
forceSsl: false,
|
|
},
|
|
mockDataStore,
|
|
mockServiceManager
|
|
)
|
|
|
|
const args = capturedUpdateArgs
|
|
expect(args[3]).toBe(3) // instanceCount — patched
|
|
expect(args[2]).toBe('Updated description') // description — patched
|
|
expect(args[12]).toBe(false) // forceSsl — patched
|
|
|
|
// Non-provided fields preserved
|
|
expect(args[5]).toEqual(mockExistingApp.envVars)
|
|
expect(args[6]).toEqual(mockExistingApp.volumes)
|
|
expect(args[19]).toBe(true) // websocketSupport preserved
|
|
})
|
|
|
|
it('should allow updating env vars explicitly', async () => {
|
|
const newEnvVars = [{ key: 'NEW_VAR', value: 'new-value' }]
|
|
|
|
await patchAppDefinition(
|
|
'my-app',
|
|
{ appName: 'my-app', envVars: newEnvVars },
|
|
mockDataStore,
|
|
mockServiceManager
|
|
)
|
|
|
|
const args = capturedUpdateArgs
|
|
expect(args[5]).toEqual(newEnvVars) // envVars — patched
|
|
expect(args[5]).toHaveLength(1)
|
|
expect(args[3]).toBe(2) // instanceCount preserved from existing
|
|
})
|
|
|
|
it('should allow setting envVars to empty array explicitly', async () => {
|
|
await patchAppDefinition(
|
|
'my-app',
|
|
{ appName: 'my-app', envVars: [] },
|
|
mockDataStore,
|
|
mockServiceManager
|
|
)
|
|
|
|
const args = capturedUpdateArgs
|
|
expect(args[5]).toEqual([]) // envVars — explicitly emptied
|
|
expect(args[3]).toBe(2) // instanceCount preserved
|
|
expect(args[6]).toEqual(mockExistingApp.volumes) // volumes preserved
|
|
})
|
|
|
|
it('should throw error when appName is missing', async () => {
|
|
await expect(
|
|
patchAppDefinition(
|
|
'',
|
|
{ instanceCount: 1 },
|
|
mockDataStore,
|
|
mockServiceManager
|
|
)
|
|
).rejects.toThrow()
|
|
})
|
|
|
|
it('should preserve httpAuth from existing app', async () => {
|
|
await patchAppDefinition(
|
|
'my-app',
|
|
{ appName: 'my-app', instanceCount: 1 },
|
|
mockDataStore,
|
|
mockServiceManager
|
|
)
|
|
|
|
const args = capturedUpdateArgs
|
|
// httpAuth is arg 11
|
|
expect(args[11]).toEqual(mockExistingApp.httpAuth)
|
|
})
|
|
|
|
it('should handle multiple fields updated at once', async () => {
|
|
await patchAppDefinition(
|
|
'my-app',
|
|
{
|
|
appName: 'my-app',
|
|
instanceCount: 5,
|
|
containerHttpPort: 8080,
|
|
websocketSupport: false,
|
|
notExposeAsWebApp: true,
|
|
redirectDomain: 'example.com',
|
|
},
|
|
mockDataStore,
|
|
mockServiceManager
|
|
)
|
|
|
|
const args = capturedUpdateArgs
|
|
expect(args[3]).toBe(5) // instanceCount
|
|
expect(args[10]).toBe(8080) // containerHttpPort
|
|
expect(args[19]).toBe(false) // websocketSupport
|
|
expect(args[9]).toBe(true) // notExposeAsWebApp
|
|
expect(args[16]).toBe('example.com') // redirectDomain
|
|
|
|
// Non-provided preserved
|
|
expect(args[5]).toEqual(mockExistingApp.envVars)
|
|
expect(args[12]).toBe(true) // forceSsl
|
|
expect(args[2]).toBe('My test app') // description
|
|
})
|
|
})
|