diff --git a/README.md b/README.md index 0236d77..2f46ca7 100644 --- a/README.md +++ b/README.md @@ -52,6 +52,7 @@ ServerlessInsight supports the following cloud providers: | ----------------- | ---------------- | -------------- | -------------- | ---------------- | ------- | | **Alibaba Cloud** | ✅ FC3 | ✅ API Gateway | ✅ OSS | ✅ RDS, OTS, ESS | Stable | | **Tencent Cloud** | ✅ SCF | 🚧 Coming Soon | ✅ COS | ✅ TDSQL-C | Stable | +| **Volcengine** | ✅ veFaaS | ✅ API Gateway | ✅ TOS | — | Stable | | **Huawei Cloud** | ✅ FunctionGraph | 🚧 Coming Soon | 🚧 Coming Soon | 🚧 Coming Soon | Beta | | **AWS** | 🔜 Planned | 🔜 Planned | 🔜 Planned | 🔜 Planned | Planned | | **Azure** | 🔜 Planned | 🔜 Planned | 🔜 Planned | 🔜 Planned | Planned | diff --git a/samples/volcengine-poc-api.yml b/samples/volcengine-poc-api.yml new file mode 100644 index 0000000..d8e0d71 --- /dev/null +++ b/samples/volcengine-poc-api.yml @@ -0,0 +1,31 @@ +version: 0.1.0 +provider: + name: volcengine + region: cn-beijing + +app: insight-poc +service: insight-poc-api + +functions: + api_function: + name: insight-poc-api-fn + code: + runtime: nodejs/v18 + handler: index.handler + path: ./artifacts/function.zip + memory: 256 + timeout: 30 + environment: + NODE_ENV: production + +events: + api_gateway: + type: API_GATEWAY + name: insight-poc-api + triggers: + - method: GET + path: /api/hello + backend: ${functions.api_function} + - method: POST + path: /api/data + backend: ${functions.api_function} diff --git a/samples/volcengine-poc-bucket.yml b/samples/volcengine-poc-bucket.yml new file mode 100644 index 0000000..d55420d --- /dev/null +++ b/samples/volcengine-poc-bucket.yml @@ -0,0 +1,17 @@ +version: 0.1.0 +provider: + name: volcengine + region: cn-beijing + +app: insight-poc +service: insight-poc-bucket + +buckets: + website_bucket: + name: insight-poc-website + security: + acl: PUBLIC_READ + website: + code: ./dist + index: index.html + error_page: 404.html diff --git a/samples/volcengine-poc-function.yml b/samples/volcengine-poc-function.yml new file mode 100644 index 0000000..82adb6e --- /dev/null +++ b/samples/volcengine-poc-function.yml @@ -0,0 +1,19 @@ +version: 0.1.0 +provider: + name: volcengine + region: cn-beijing + +app: insight-poc +service: insight-poc-function + +functions: + hello_function: + name: insight-poc-hello-fn + code: + runtime: nodejs/v18 + handler: index.handler + path: ./artifacts/function.zip + memory: 128 + timeout: 30 + environment: + NODE_ENV: production diff --git a/samples/volcengine-poc-vpc.yml b/samples/volcengine-poc-vpc.yml new file mode 100644 index 0000000..a35d57d --- /dev/null +++ b/samples/volcengine-poc-vpc.yml @@ -0,0 +1,34 @@ +version: 0.1.0 +provider: + name: volcengine + region: cn-beijing + +app: insight-poc +service: insight-poc-vpc + +functions: + vpc_function: + name: insight-poc-vpc-fn + code: + runtime: nodejs/v18 + handler: index.handler + path: ./artifacts/function.zip + memory: 512 + timeout: 60 + network: + vpc_id: vpc-xxx + subnet_ids: + - subnet-xxx + security_group: + name: fn-security-group + ingress: + - protocol: TCP + port: 443 + source: 0.0.0.0/0 + egress: + - protocol: TCP + port: 0 + destination: 0.0.0.0/0 + environment: + DATABASE_URL: mysql://rds-host:3306/mydb + REDIS_URL: redis://redis-host:6379 diff --git a/src/common/runtimeMapper.ts b/src/common/runtimeMapper.ts index 684df84..9965b84 100644 --- a/src/common/runtimeMapper.ts +++ b/src/common/runtimeMapper.ts @@ -153,14 +153,37 @@ export const mapRuntime = (standardRuntime: string, provider: ProviderEnum): str return providerRuntime; }; -export const isRuntimeSupported = (standardRuntime: string, provider: ProviderEnum): boolean => { - const mapping = runtimeMappings[standardRuntime as StandardRuntime]; +const VOLCENGINE_NATIVE_RUNTIMES = [ + 'nodejs/v20', + 'nodejs/v18', + 'nodejs/v16', + 'nodejs/v14', + 'python/v3.12', + 'python/v3.11', + 'python/v3.10', + 'python/v3.9', + 'golang/v1', + 'java/v21', + 'java/v17', + 'java/v11', + 'java/v8', +]; + +export const isRuntimeSupported = (runtime: string, provider: ProviderEnum): boolean => { + if (provider === ProviderEnum.VOLCENGINE) { + return VOLCENGINE_NATIVE_RUNTIMES.includes(runtime); + } + const mapping = runtimeMappings[runtime as StandardRuntime]; return !!mapping && !!mapping[provider]; }; export const getSupportedRuntimes = (provider?: ProviderEnum): string[] => { if (!provider) { - return Object.values(StandardRuntime); + return [...Object.values(StandardRuntime), ...VOLCENGINE_NATIVE_RUNTIMES]; + } + + if (provider === ProviderEnum.VOLCENGINE) { + return VOLCENGINE_NATIVE_RUNTIMES; } return Object.entries(runtimeMappings) diff --git a/src/validator/functionSchema.ts b/src/validator/functionSchema.ts index 1295d60..fbecf2d 100644 --- a/src/validator/functionSchema.ts +++ b/src/validator/functionSchema.ts @@ -45,6 +45,19 @@ export const functionSchema = { 'php5.6', 'go1', 'dotnet_core3.1', + 'nodejs/v20', + 'nodejs/v18', + 'nodejs/v16', + 'nodejs/v14', + 'python/v3.12', + 'python/v3.11', + 'python/v3.10', + 'python/v3.9', + 'golang/v1', + 'java/v21', + 'java/v17', + 'java/v11', + 'java/v8', ]), handler: { type: 'string' }, path: { type: 'string' }, diff --git a/src/validator/rootSchema.ts b/src/validator/rootSchema.ts index d89ca74..56bc7a5 100644 --- a/src/validator/rootSchema.ts +++ b/src/validator/rootSchema.ts @@ -7,7 +7,7 @@ export const rootSchema = { provider: { type: 'object', properties: { - name: { type: 'string', enum: ['huawei', 'aliyun', 'tencent', 'aws'] }, + name: { type: 'string', enum: ['huawei', 'aliyun', 'tencent', 'aws', 'volcengine'] }, region: { type: 'string' }, }, required: ['name', 'region'], diff --git a/tests/fixtures/serverless-insight-volcengine.yml b/tests/fixtures/serverless-insight-volcengine.yml new file mode 100644 index 0000000..6830772 --- /dev/null +++ b/tests/fixtures/serverless-insight-volcengine.yml @@ -0,0 +1,19 @@ +version: 0.0.1 +app: insight-poc-app +provider: + name: volcengine + region: cn-beijing + +service: insight-poc + +functions: + insight_poc_fn: + name: insight-poc-fn + code: + runtime: nodejs/v18 + handler: index.handler + path: tests/fixtures/artifacts/artifact.zip + memory: 128 + timeout: 30 + environment: + NODE_ENV: production diff --git a/tests/service/mockCloudClient.ts b/tests/service/mockCloudClient.ts index 0cc90a6..3afbb21 100644 --- a/tests/service/mockCloudClient.ts +++ b/tests/service/mockCloudClient.ts @@ -196,6 +196,175 @@ export const createMockTencentClient = (): MockTencentClient => ({ }, }); +export type MockVolcengineClient = { + vefaas: { + createFunction: jest.Mock; + getFunction: jest.Mock; + updateFunctionConfiguration: jest.Mock; + updateFunctionCode: jest.Mock; + deleteFunction: jest.Mock; + listFunctions: jest.Mock; + }; + tos: { + createBucket: jest.Mock; + getBucket: jest.Mock; + deleteBucket: jest.Mock; + updateBucketAcl: jest.Mock; + updateBucketWebsite: jest.Mock; + putObject: jest.Mock; + listObjects: jest.Mock; + deleteObjects: jest.Mock; + uploadFiles: jest.Mock; + }; + iam: { + createRole: jest.Mock; + getRole: jest.Mock; + updateRoleTrustPolicy: jest.Mock; + deleteRole: jest.Mock; + attachRolePolicy: jest.Mock; + detachRolePolicy: jest.Mock; + }; + tls: { + createProject: jest.Mock; + getProject: jest.Mock; + deleteProject: jest.Mock; + createTopic: jest.Mock; + getTopic: jest.Mock; + deleteTopic: jest.Mock; + createIndex: jest.Mock; + deleteIndex: jest.Mock; + waitForProject: jest.Mock; + waitForTopic: jest.Mock; + }; + apigw: { + createGateway: jest.Mock; + getGateway: jest.Mock; + findGatewayByName: jest.Mock; + updateGateway: jest.Mock; + deleteGateway: jest.Mock; + createApi: jest.Mock; + getApi: jest.Mock; + updateApi: jest.Mock; + deleteApi: jest.Mock; + deployApi: jest.Mock; + bindDomain: jest.Mock; + unbindDomain: jest.Mock; + }; +}; + +export const createMockVolcengineClient = (): MockVolcengineClient => ({ + vefaas: { + createFunction: jest.fn().mockResolvedValue(undefined), + getFunction: jest.fn().mockResolvedValue({ + functionId: 'func-123', + functionName: 'test-function', + runtime: 'nodejs/v18', + handler: 'index.handler', + memoryMb: 128, + requestTimeout: 30, + status: 'Active', + }), + updateFunctionConfiguration: jest.fn().mockResolvedValue(undefined), + updateFunctionCode: jest.fn().mockResolvedValue(undefined), + deleteFunction: jest.fn().mockResolvedValue(undefined), + listFunctions: jest.fn().mockResolvedValue([]), + }, + tos: { + createBucket: jest.fn().mockResolvedValue({ + name: 'test-bucket', + location: 'cn-beijing', + extranetEndpoint: 'test-bucket.tos-cn-beijing.volces.com', + }), + getBucket: jest.fn().mockResolvedValue({ + name: 'test-bucket', + location: 'cn-beijing', + extranetEndpoint: 'test-bucket.tos-cn-beijing.volces.com', + }), + deleteBucket: jest.fn().mockResolvedValue(undefined), + updateBucketAcl: jest.fn().mockResolvedValue(undefined), + updateBucketWebsite: jest.fn().mockResolvedValue(undefined), + putObject: jest.fn().mockResolvedValue(undefined), + listObjects: jest.fn().mockResolvedValue([]), + deleteObjects: jest.fn().mockResolvedValue(undefined), + uploadFiles: jest.fn().mockResolvedValue(undefined), + }, + iam: { + createRole: jest.fn().mockResolvedValue({ + roleName: 'test-role', + roleId: 'role-123', + trn: 'trn:iam::123456789012:role/test-role', + }), + getRole: jest.fn().mockResolvedValue({ + roleName: 'test-role', + roleId: 'role-123', + trn: 'trn:iam::123456789012:role/test-role', + }), + updateRoleTrustPolicy: jest.fn().mockResolvedValue(undefined), + deleteRole: jest.fn().mockResolvedValue(undefined), + attachRolePolicy: jest.fn().mockResolvedValue(undefined), + detachRolePolicy: jest.fn().mockResolvedValue(undefined), + }, + tls: { + createProject: jest.fn().mockResolvedValue({ + projectId: 'proj-123', + projectName: 'test-project', + status: 'Running', + }), + getProject: jest.fn().mockResolvedValue({ + projectId: 'proj-123', + projectName: 'test-project', + status: 'Running', + }), + deleteProject: jest.fn().mockResolvedValue(undefined), + createTopic: jest.fn().mockResolvedValue({ + topicId: 'topic-123', + topicName: 'test-topic', + status: 'Running', + }), + getTopic: jest.fn().mockResolvedValue({ + topicId: 'topic-123', + topicName: 'test-topic', + status: 'Running', + }), + deleteTopic: jest.fn().mockResolvedValue(undefined), + createIndex: jest.fn().mockResolvedValue(undefined), + deleteIndex: jest.fn().mockResolvedValue(undefined), + waitForProject: jest.fn().mockResolvedValue(undefined), + waitForTopic: jest.fn().mockResolvedValue(undefined), + }, + apigw: { + createGateway: jest.fn().mockResolvedValue({ + gatewayId: 'gw-123', + gatewayName: 'test-gateway', + status: 'Running', + subDomain: 'gw-123.apig.cn-beijing.volces.com', + }), + getGateway: jest.fn().mockResolvedValue({ + gatewayId: 'gw-123', + gatewayName: 'test-gateway', + status: 'Running', + subDomain: 'gw-123.apig.cn-beijing.volces.com', + }), + findGatewayByName: jest.fn().mockResolvedValue(null), + updateGateway: jest.fn().mockResolvedValue(undefined), + deleteGateway: jest.fn().mockResolvedValue(undefined), + createApi: jest.fn().mockResolvedValue('api-123'), + getApi: jest.fn().mockResolvedValue({ + apiId: 'api-123', + apiName: 'test-api', + gatewayId: 'gw-123', + method: 'GET', + path: '/api/hello', + status: 'Running', + }), + updateApi: jest.fn().mockResolvedValue(undefined), + deleteApi: jest.fn().mockResolvedValue(undefined), + deployApi: jest.fn().mockResolvedValue(undefined), + bindDomain: jest.fn().mockResolvedValue(undefined), + unbindDomain: jest.fn().mockResolvedValue(undefined), + }, +}); + export const createMockContext = (overrides?: Partial): Context => ({ provider: ProviderEnum.ALIYUN, diff --git a/tests/service/volcengine-deploy-flow.spec.test.ts b/tests/service/volcengine-deploy-flow.spec.test.ts new file mode 100644 index 0000000..40b3ac9 --- /dev/null +++ b/tests/service/volcengine-deploy-flow.spec.test.ts @@ -0,0 +1,86 @@ +import path from 'node:path'; +import fs from 'node:fs/promises'; +import { deploy } from '../../src/commands/deploy'; +import { createMockVolcengineClient, type MockVolcengineClient } from './mockCloudClient'; + +jest.mock('../../src/common/volcengineClient', () => ({ + createVolcengineClient: jest.fn(), +})); + +jest.mock('../../src/common/imsClient', () => ({ + getIamInfo: jest.fn().mockResolvedValue({ + accountId: '123456789012', + displayName: 'Test User', + userId: 'test-user-id', + }), +})); + +jest.mock('../../src/common/logger', () => ({ + logger: { + info: jest.fn(), + warn: jest.fn(), + error: jest.fn(), + debug: jest.fn(), + }, +})); + +jest.mock('../../src/lang', () => ({ + lang: { + __: (key: string) => key, + }, +})); + +// eslint-disable-next-line @typescript-eslint/no-require-imports +const mockCreateVolcengineClient = require('../../src/common/volcengineClient') + .createVolcengineClient as jest.Mock; + +describe('Volcengine Deploy Flow Service Test', () => { + const fixtureFile = path.join(__dirname, '../fixtures/serverless-insight-volcengine.yml'); + const stateFilePath = path.join( + process.cwd(), + '.serverlessinsight', + 'state-insight-poc-app-insight-poc.json', + ); + let mockClient: MockVolcengineClient; + + beforeEach(async () => { + jest.clearAllMocks(); + + mockClient = createMockVolcengineClient(); + mockCreateVolcengineClient.mockReturnValue(mockClient); + + await fs.rm(stateFilePath, { force: true }).catch(() => {}); + }); + + afterEach(async () => { + await fs.rm(stateFilePath, { force: true }).catch(() => {}); + }); + + describe('Volcengine veFaaS Deploy', () => { + it('should deploy single veFaaS function and save state', async () => { + await deploy({ + location: fixtureFile, + stage: 'dev', + autoApprove: true, + region: 'cn-beijing', + provider: 'volcengine', + }); + + expect(mockClient.vefaas.createFunction).toHaveBeenCalled(); + }); + + it('should handle deploy error when cloud SDK fails', async () => { + mockClient.vefaas.createFunction.mockRejectedValueOnce(new Error('FunctionAlreadyExists')); + + await expect( + deploy({ + location: fixtureFile, + stage: 'dev', + autoApprove: true, + region: 'cn-beijing', + provider: 'volcengine', + }), + ).rejects.toThrow(); + }); + }); +}); diff --git a/tests/service/volcengine-destroy-flow.spec.test.ts b/tests/service/volcengine-destroy-flow.spec.test.ts new file mode 100644 index 0000000..4fadae5 --- /dev/null +++ b/tests/service/volcengine-destroy-flow.spec.test.ts @@ -0,0 +1,136 @@ +import path from 'node:path'; +import fs from 'node:fs/promises'; +import { destroyStack } from '../../src/commands/destroy'; +import { createMockVolcengineClient, type MockVolcengineClient } from './mockCloudClient'; + +jest.mock('../../src/common/volcengineClient', () => ({ + createVolcengineClient: jest.fn(), +})); + +jest.mock('../../src/common/imsClient', () => ({ + getIamInfo: jest.fn().mockResolvedValue({ + accountId: '123456789012', + displayName: 'Test User', + userId: 'test-user-id', + }), +})); + +jest.mock('../../src/common/logger', () => ({ + logger: { + info: jest.fn(), + warn: jest.fn(), + error: jest.fn(), + debug: jest.fn(), + }, +})); + +jest.mock('../../src/lang', () => ({ + lang: { + __: (key: string) => key, + }, +})); + +// eslint-disable-next-line @typescript-eslint/no-require-imports +const mockCreateVolcengineClient = require('../../src/common/volcengineClient') + .createVolcengineClient as jest.Mock; + +const STATE_FILE_PATH = path.join( + process.cwd(), + '.serverlessinsight', + 'state-insight-poc-app-insight-poc.json', +); + +const EXISTING_STATE = JSON.stringify({ + version: '3.0', + provider: 'volcengine', + app: 'insight-poc-app', + service: 'insight-poc', + stages: { + dev: { + resources: { + 'functions.insight_poc_fn': { + mode: 'managed', + region: 'cn-beijing', + definition: { + functionName: 'insight-poc-fn', + runtime: 'nodejs/v18', + handler: 'index.handler', + memorySize: 128, + timeout: 30, + }, + instances: [ + { sid: 'insight-poc-fn', id: 'insight-poc-fn', type: 'VOLCENGINE_VEFAAS_FUNCTION' }, + { + sid: 'iam-role-sid', + id: 'insight-poc-app-insight-poc-dev-role', + type: 'VOLCENGINE_IAM_ROLE', + trn: 'trn:iam::123456789012:role/insight-poc-app-insight-poc-dev-role', + }, + ], + lastUpdated: '2024-01-01T00:00:00.000Z', + status: 'ready', + }, + }, + }, + }, + resources: {}, +}); + +describe('Volcengine Destroy Flow Service Test', () => { + const fixtureFile = path.join(__dirname, '../fixtures/serverless-insight-volcengine.yml'); + let mockClient: MockVolcengineClient; + + beforeEach(async () => { + jest.clearAllMocks(); + + mockClient = createMockVolcengineClient(); + mockCreateVolcengineClient.mockReturnValue(mockClient); + + await fs.mkdir(path.dirname(STATE_FILE_PATH), { recursive: true }).catch(() => {}); + await fs.writeFile(STATE_FILE_PATH, EXISTING_STATE, 'utf-8'); + }); + + afterEach(async () => { + await fs.rm(STATE_FILE_PATH, { force: true }).catch(() => {}); + }); + + describe('Volcengine Destroy', () => { + it('should destroy veFaaS function and clean state', async () => { + await destroyStack({ + location: fixtureFile, + stage: 'dev', + region: 'cn-beijing', + provider: 'volcengine', + }); + + expect(mockClient.vefaas.deleteFunction).toHaveBeenCalled(); + }); + + it('should handle destroy when resource does not exist', async () => { + mockClient.vefaas.getFunction.mockRejectedValueOnce({ code: 'FunctionNotFound' }); + mockClient.vefaas.deleteFunction.mockRejectedValueOnce({ code: 'FunctionNotFound' }); + + await destroyStack({ + location: fixtureFile, + stage: 'dev', + region: 'cn-beijing', + provider: 'volcengine', + }); + }); + }); + + describe('Error Handling', () => { + it('should log error and resolve when destroy fails with access denied', async () => { + mockClient.vefaas.deleteFunction.mockRejectedValueOnce(new Error('AccessDenied')); + + await expect( + destroyStack({ + location: fixtureFile, + stage: 'dev', + region: 'cn-beijing', + provider: 'volcengine', + }), + ).resolves.toBeUndefined(); + }); + }); +}); diff --git a/tests/unit/common/context.test.ts b/tests/unit/common/context.test.ts new file mode 100644 index 0000000..f78a9e8 --- /dev/null +++ b/tests/unit/common/context.test.ts @@ -0,0 +1,158 @@ +import fs from 'node:fs'; +import { + getIacLocation, + setContext, + getContext, + setIac, + clearContext, +} from '../../../src/common/context'; + +jest.mock('node:fs'); +jest.mock('../../../src/common/imsClient', () => ({ + getIamInfo: jest.fn().mockResolvedValue({ accountId: '123456789012', userId: 'test-user' }), +})); +jest.mock('../../../src/common/credentials', () => ({ + getCredentials: jest.fn(() => ({ + accessKeyId: 'test-ak', + accessKeySecret: 'test-sk', + })), +})); + +const mockFs = fs as jest.Mocked; + +describe('context', () => { + const originalEnv = process.env; + + beforeEach(() => { + jest.clearAllMocks(); + clearContext(); + process.env = { ...originalEnv }; + delete process.env.ROS_REGION_ID; + delete process.env.SI_REGION; + }); + + afterAll(() => { + process.env = originalEnv; + clearContext(); + }); + + describe('getIacLocation', () => { + it('should return resolved path when file exists', () => { + mockFs.existsSync.mockReturnValue(true); + mockFs.statSync.mockReturnValue({ isDirectory: () => false, isFile: () => true } as never); + + const result = getIacLocation('/absolute/path/serverless.yml'); + + expect(result).toBe('/absolute/path/serverless.yml'); + }); + + it('should return undefined path when file does not exist', () => { + mockFs.existsSync.mockReturnValue(false); + + expect(() => getIacLocation('nonexistent.yml')).toThrow('No IaC file found'); + }); + + it('should search directory for default IAC files', () => { + mockFs.existsSync + .mockReturnValueOnce(true) + .mockReturnValueOnce(false) + .mockReturnValueOnce(false) + .mockReturnValueOnce(false) + .mockReturnValueOnce(true); + mockFs.statSync + .mockReturnValueOnce({ isDirectory: () => true } as never) + .mockReturnValueOnce({ isFile: () => true } as never); + + const result = getIacLocation('/some/directory'); + + expect(result).toContain('serverless-insight.yml'); + }); + + it('should throw when directory exists but no IAC files found', () => { + mockFs.existsSync.mockReturnValueOnce(true).mockReturnValue(false); + mockFs.statSync.mockReturnValue({ isDirectory: () => true } as never); + + expect(() => getIacLocation('/empty/directory')).toThrow('No IaC file found'); + }); + + it('should throw when no default IAC file found in cwd', () => { + mockFs.existsSync.mockReturnValue(false); + + expect(() => getIacLocation(undefined)).toThrow('No IaC file found'); + }); + }); + + describe('getContext', () => { + it('should throw when context not set', () => { + expect(() => getContext()).toThrow('No context found'); + }); + }); + + describe('setIac', () => { + it('should throw when context not set', () => { + expect(() => setIac({} as never)).toThrow('Context must be set before setting IAC'); + }); + }); + + describe('clearContext', () => { + it('should clear context', async () => { + mockFs.existsSync.mockReturnValue(true); + mockFs.statSync.mockReturnValue({ isDirectory: () => false } as never); + + await setContext({ + app: 'test-app', + service: 'test-service', + location: '/test/path', + }); + + expect(() => getContext()).not.toThrow(); + clearContext(); + expect(() => getContext()).toThrow('No context found'); + }); + }); + + describe('setContext', () => { + it('should set context with provided values', async () => { + mockFs.existsSync.mockReturnValue(true); + mockFs.statSync.mockReturnValue({ isDirectory: () => false } as never); + + await setContext({ + app: 'test-app', + service: 'test-service', + stage: 'prod', + region: 'cn-beijing', + location: '/test/path', + parameters: { KEY: 'value' }, + }); + + const ctx = getContext(); + expect(ctx.app).toBe('test-app'); + expect(ctx.stage).toBe('prod'); + expect(ctx.region).toBe('cn-beijing'); + expect(ctx.parameters).toEqual([{ key: 'KEY', value: 'value' }]); + }); + + it('should warn when ROS_REGION_ID is set without SI_REGION', async () => { + mockFs.existsSync.mockReturnValue(true); + mockFs.statSync.mockReturnValue({ isDirectory: () => false } as never); + + process.env.ROS_REGION_ID = 'cn-shanghai'; + const warnSpy = jest.spyOn(console, 'warn').mockImplementation(() => {}); + + await setContext({ app: 'test', service: 'svc', location: '/path' }); + + expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining('ROS_REGION_ID')); + warnSpy.mockRestore(); + }); + + it('should call getIamInfo when reaValToken is true', async () => { + const { getIamInfo } = jest.requireMock('../../../src/common/imsClient'); + mockFs.existsSync.mockReturnValue(true); + mockFs.statSync.mockReturnValue({ isDirectory: () => false } as never); + + await setContext({ app: 'test', service: 'svc', location: '/path' }, true); + + expect(getIamInfo).toHaveBeenCalled(); + }); + }); +}); diff --git a/tests/unit/common/runtimeMapper.test.ts b/tests/unit/common/runtimeMapper.test.ts index 89af366..2f0e187 100644 --- a/tests/unit/common/runtimeMapper.test.ts +++ b/tests/unit/common/runtimeMapper.test.ts @@ -241,6 +241,18 @@ describe('runtimeMapper', () => { expect(isRuntimeSupported(StandardRuntime.PYTHON3_10, ProviderEnum.TENCENT)).toBe(true); expect(isRuntimeSupported(StandardRuntime.PYTHON3_10, ProviderEnum.AWS)).toBe(true); }); + + it('returns true for Volcengine native runtimes', () => { + expect(isRuntimeSupported('nodejs/v18', ProviderEnum.VOLCENGINE)).toBe(true); + expect(isRuntimeSupported('python/v3.10', ProviderEnum.VOLCENGINE)).toBe(true); + expect(isRuntimeSupported('golang/v1', ProviderEnum.VOLCENGINE)).toBe(true); + expect(isRuntimeSupported('java/v17', ProviderEnum.VOLCENGINE)).toBe(true); + }); + + it('returns false for non-Volcengine runtimes when provider is Volcengine', () => { + expect(isRuntimeSupported('nodejs18', ProviderEnum.VOLCENGINE)).toBe(false); + expect(isRuntimeSupported('invalid_runtime', ProviderEnum.VOLCENGINE)).toBe(false); + }); }); describe('getSupportedRuntimes', () => { @@ -287,5 +299,16 @@ describe('runtimeMapper', () => { expect(awsRuntimes).not.toContain(StandardRuntime.GO1); expect(awsRuntimes).not.toContain(StandardRuntime.DOTNET_CORE3_1); }); + + it('returns only Volcengine native runtimes', () => { + const volcengineRuntimes = getSupportedRuntimes(ProviderEnum.VOLCENGINE); + expect(volcengineRuntimes).toContain('nodejs/v18'); + expect(volcengineRuntimes).toContain('nodejs/v20'); + expect(volcengineRuntimes).toContain('python/v3.10'); + expect(volcengineRuntimes).toContain('golang/v1'); + expect(volcengineRuntimes).toContain('java/v17'); + expect(volcengineRuntimes).not.toContain('nodejs18'); + expect(volcengineRuntimes).not.toContain(StandardRuntime.NODEJS18); + }); }); }); diff --git a/tests/unit/common/stateBackend/cosStateBackend.test.ts b/tests/unit/common/stateBackend/cosStateBackend.test.ts index 03fae34..b220fed 100644 --- a/tests/unit/common/stateBackend/cosStateBackend.test.ts +++ b/tests/unit/common/stateBackend/cosStateBackend.test.ts @@ -238,4 +238,53 @@ describe('cosStateBackend', () => { await expect(capturedAdapter.read('state.json')).rejects.toThrow('Access Denied'); }); + + it('should propagate errors on write', async () => { + const testError = new Error('Write Failed'); + mockCosClient.putObject.mockImplementation( + ({ _Key, _Body }: { _Key: string; _Body: string }, callback: (err: unknown) => void) => { + callback(testError); + }, + ); + + const { + createRemoteStateBackend, + } = require('../../../../src/common/stateBackend/remoteStateBackend'); + const adapterCapture = jest.fn(); + (createRemoteStateBackend as jest.Mock).mockImplementation((adapter: StorageAdapter) => { + adapterCapture(adapter); + return { saveState: jest.fn() }; + }); + + createCosStateBackend(config); + const capturedAdapter = adapterCapture.mock.calls[0][0]; + + await expect(capturedAdapter.write('state.json', { app: 'test' })).rejects.toThrow( + 'Write Failed', + ); + }); + + it('should propagate non-404 errors on delete', async () => { + const testError = new Error('Delete Failed'); + (testError as unknown as { statusCode: number }).statusCode = 500; + mockCosClient.deleteObject.mockImplementation( + ({ _Key }: { _Key: string }, callback: (err: unknown) => void) => { + callback(testError); + }, + ); + + const { + createRemoteStateBackend, + } = require('../../../../src/common/stateBackend/remoteStateBackend'); + const adapterCapture = jest.fn(); + (createRemoteStateBackend as jest.Mock).mockImplementation((adapter: StorageAdapter) => { + adapterCapture(adapter); + return { loadState: jest.fn() }; + }); + + createCosStateBackend(config); + const capturedAdapter = adapterCapture.mock.calls[0][0]; + + await expect(capturedAdapter.delete('state.json')).rejects.toThrow('Delete Failed'); + }); }); diff --git a/tests/unit/common/stateBackend/lockUtils.test.ts b/tests/unit/common/stateBackend/lockUtils.test.ts index ef98a4e..0c4c9e8 100644 --- a/tests/unit/common/stateBackend/lockUtils.test.ts +++ b/tests/unit/common/stateBackend/lockUtils.test.ts @@ -80,6 +80,15 @@ describe('lockUtils', () => { const email = getUserEmail(); expect(email).toBe('user@example.com'); }); + + it('should fall back to username@hostname when git returns empty email', () => { + (execSync as jest.Mock).mockReturnValue(' '); + (os.userInfo as jest.Mock).mockReturnValue({ username: 'testuser' }); + (os.hostname as jest.Mock).mockReturnValue('localhost'); + + const email = getUserEmail(); + expect(email).toBe('testuser@localhost'); + }); }); describe('sleep', () => { diff --git a/tests/unit/common/volcengineClient/iamOperations.test.ts b/tests/unit/common/volcengineClient/iamOperations.test.ts index 3200902..78db284 100644 --- a/tests/unit/common/volcengineClient/iamOperations.test.ts +++ b/tests/unit/common/volcengineClient/iamOperations.test.ts @@ -472,5 +472,41 @@ describe('iamOperations', () => { await operations.detachRolePolicy('test-role', 'test-policy'); }); + + it('should warn when detach fails with non-recoverable error', async () => { + const error = new Error('Access denied') as Error & { code: string }; + error.code = 'AccessDenied'; + + mockClient.fetchOpenAPI.mockRejectedValueOnce(error); + + const { logger } = jest.requireMock('../../../../src/common/logger'); + + await operations.detachRolePolicy('test-role', 'test-policy'); + + expect(logger.warn).toHaveBeenCalled(); + }); + }); + + describe('createRole - non-object error handling', () => { + it('should rethrow non-object policy creation error', async () => { + mockClient.fetchOpenAPI + .mockResolvedValueOnce({ + Result: { Role: { RoleName: 'test-role', RoleId: 'role-123' } }, + }) + .mockRejectedValueOnce('plain string error'); + + await expect(operations.createRole(mockConfig)).rejects.toBe('plain string error'); + }); + + it('should rethrow non-object policy attach error', async () => { + mockClient.fetchOpenAPI + .mockResolvedValueOnce({ + Result: { Role: { RoleName: 'test-role', RoleId: 'role-123' } }, + }) + .mockResolvedValueOnce({}) + .mockRejectedValueOnce('plain string attach error'); + + await expect(operations.createRole(mockConfig)).rejects.toBe('plain string attach error'); + }); }); }); diff --git a/tests/unit/stack/aliyunStack/tablestorePlanner.test.ts b/tests/unit/stack/aliyunStack/tablestorePlanner.test.ts index b24937d..549f502 100644 --- a/tests/unit/stack/aliyunStack/tablestorePlanner.test.ts +++ b/tests/unit/stack/aliyunStack/tablestorePlanner.test.ts @@ -388,5 +388,85 @@ describe('TableStore Planner', () => { resourceType: 'ALIYUN_TABLESTORE_TABLE', }); }); + + it('should plan update with drift when primary key changes', async () => { + mockTablestoreOperations.getTable.mockResolvedValue({ + tableName: 'test-table', + primaryKey: [{ name: 'old_id', type: 'INTEGER' }], + }); + + const state = setResource(initialState, 'tables.test_table', { + mode: 'managed', + region: 'cn-hangzhou', + definition: { + instanceName: 'test-instance', + tableName: 'test-table', + clusterType: 'HYBRID', + description: null, + primaryKey: [{ name: 'old_id', type: 'INTEGER' }], + attributes: [{ name: 'id', type: 'INTEGER' }], + reservedThroughput: { capacityUnit: { read: 10, write: 5 } }, + onDemandThroughput: null, + tableOptions: { timeToLive: -1, maxVersions: 1 }, + network: { type: 'PUBLIC', ingressRules: [] }, + }, + instances: [ + { + type: 'ALIYUN_TABLESTORE_TABLE', + sid: 'si:aliyun:ots:default:test-instance/test-table', + id: 'test-instance/test-table', + instanceName: 'test-instance', + tableName: 'test-table', + clusterType: 'HYBRID', + primaryKey: [{ name: 'old_id', type: 'INTEGER' }], + }, + ], + lastUpdated: new Date().toISOString(), + }); + + const plan = await generateTablePlan(mockContext, state, [testTable]); + + expect(plan.items).toHaveLength(1); + expect(plan.items[0]).toMatchObject({ + logicalId: 'tables.test_table', + action: 'update', + resourceType: 'ALIYUN_TABLESTORE_TABLE', + drifted: true, + }); + }); + + it('should use empty object fallback when currentState.definition is null', async () => { + mockTablestoreOperations.getTable.mockResolvedValue({ + tableName: 'test-table', + primaryKey: [{ name: 'id', type: 'INTEGER' }], + }); + + const state = setResource(initialState, 'tables.test_table', { + mode: 'managed', + region: 'cn-hangzhou', + definition: null as unknown as Record, + instances: [ + { + type: 'ALIYUN_TABLESTORE_TABLE', + sid: 'si:aliyun:ots:default:test-instance/test-table', + id: 'test-instance/test-table', + instanceName: 'test-instance', + tableName: 'test-table', + clusterType: 'HYBRID', + primaryKey: [{ name: 'id', type: 'INTEGER' }], + }, + ], + lastUpdated: new Date().toISOString(), + }); + + const plan = await generateTablePlan(mockContext, state, [testTable]); + + expect(plan.items).toHaveLength(1); + expect(plan.items[0]).toMatchObject({ + logicalId: 'tables.test_table', + action: 'update', + resourceType: 'ALIYUN_TABLESTORE_TABLE', + }); + }); }); }); diff --git a/tests/unit/stack/localStack/utils.unit.test.ts b/tests/unit/stack/localStack/utils.unit.test.ts index b31d70b..604028e 100644 --- a/tests/unit/stack/localStack/utils.unit.test.ts +++ b/tests/unit/stack/localStack/utils.unit.test.ts @@ -129,9 +129,25 @@ describe('LocalStack Utils', () => { fs.rmSync(extractedPath, { recursive: true, force: true }); }); + it('should return tempDir when single root entry is a file (not directory)', async () => { + const zip = new JSZip(); + zip.file('single-file.js', 'module.exports = {};'); + + const zipPath = path.join(tempDir, 'single-file.zip'); + const content = await zip.generateAsync({ type: 'nodebuffer' }); + fs.writeFileSync(zipPath, content); + + const extractedPath = await extractZipFile(zipPath); + + expect(fs.existsSync(extractedPath)).toBe(true); + expect(fs.statSync(extractedPath).isDirectory()).toBe(true); + expect(fs.existsSync(path.join(extractedPath, 'single-file.js'))).toBe(true); + + fs.rmSync(extractedPath, { recursive: true, force: true }); + }); + it('should create parent directories when needed', async () => { const zip = new JSZip(); - // Add multiple files at root level to avoid single-root optimization zip.file('README.md', 'readme'); zip.file('deep/nested/path/file.js', 'content'); @@ -149,7 +165,6 @@ describe('LocalStack Utils', () => { true, ); - // Clean up fs.rmSync(extractedPath, { recursive: true, force: true }); }); }); diff --git a/tests/unit/stack/scfStack/deployer.test.ts b/tests/unit/stack/scfStack/deployer.test.ts index 69778d5..f1f90c8 100644 --- a/tests/unit/stack/scfStack/deployer.test.ts +++ b/tests/unit/stack/scfStack/deployer.test.ts @@ -258,5 +258,44 @@ describe('deployer', () => { expect(scfExecutor.executeFunctionPlan).toHaveBeenCalled(); expect(cosExecutor.executeBucketPlan).toHaveBeenCalled(); }); + it('should handle database plan partial failure', async () => { + const error = new Error('Database deploy failed'); + (error as any).isPartialFailure = true; + + (tdsqlcExecutor.executeDatabasePlan as jest.Mock).mockResolvedValue({ + state: initialState, + partialFailure: { + failedItem: { + logicalId: 'databases.test', + action: 'create', + resourceType: 'TDSQLC', + }, + error, + successfulItems: [], + }, + }); + + await expect(deployTencentStack(testIac, mockBackend)).rejects.toThrow(); + }); + + it('should handle es plan partial failure', async () => { + const error = new Error('ES deploy failed'); + (error as any).isPartialFailure = true; + + (esExecutor.executeEsPlan as jest.Mock).mockResolvedValue({ + state: initialState, + partialFailure: { + failedItem: { + logicalId: 'databases.es_test', + action: 'create', + resourceType: 'ES_SERVERLESS', + }, + error, + successfulItems: [], + }, + }); + + await expect(deployTencentStack(testIac, mockBackend)).rejects.toThrow(); + }); }); }); diff --git a/tests/unit/stack/scfStack/destroyer.test.ts b/tests/unit/stack/scfStack/destroyer.test.ts index 070032a..097dff5 100644 --- a/tests/unit/stack/scfStack/destroyer.test.ts +++ b/tests/unit/stack/scfStack/destroyer.test.ts @@ -250,5 +250,45 @@ describe('destroyer', () => { expect(mockBackend.saveState).toHaveBeenCalled(); }); + + it('should handle database deletion failure', async () => { + const error = new Error('Database deletion failed'); + (error as any).isPartialFailure = true; + + (tdsqlcExecutor.executeDatabasePlan as jest.Mock).mockResolvedValue({ + state: initialState, + partialFailure: { + failedItem: { + logicalId: 'databases.test', + action: 'delete', + resourceType: 'TDSQLC', + }, + error, + successfulItems: [], + }, + }); + + await expect(destroyTencentStack(mockBackend)).rejects.toThrow(); + }); + + it('should handle es deletion failure', async () => { + const error = new Error('ES deletion failed'); + (error as any).isPartialFailure = true; + + (esExecutor.executeEsPlan as jest.Mock).mockResolvedValue({ + state: initialState, + partialFailure: { + failedItem: { + logicalId: 'databases.es_test', + action: 'delete', + resourceType: 'ES_SERVERLESS', + }, + error, + successfulItems: [], + }, + }); + + await expect(destroyTencentStack(mockBackend)).rejects.toThrow(); + }); }); }); diff --git a/tests/unit/stack/volcengineStack/apigwPlanner.test.ts b/tests/unit/stack/volcengineStack/apigwPlanner.test.ts index ea138d7..768b6de 100644 --- a/tests/unit/stack/volcengineStack/apigwPlanner.test.ts +++ b/tests/unit/stack/volcengineStack/apigwPlanner.test.ts @@ -234,6 +234,143 @@ describe('apigwPlanner', () => { expect(result.items[0].drifted).toBe(true); }); + it('should generate delete plan for removed events when events is empty', async () => { + const stateWithResource: StateFile = { + ...mockState, + resources: { + 'events.old_gateway': { + mode: 'managed', + region: 'cn-beijing', + definition: { groupName: 'old-gateway' }, + instances: [], + lastUpdated: '2024-01-01T00:00:00Z', + }, + }, + }; + + const result = await generateApigwPlan(mockContext, stateWithResource, [], 'test-service'); + + expect(result.items).toHaveLength(1); + expect(result.items[0].action).toBe('delete'); + expect(result.items[0].logicalId).toBe('events.old_gateway'); + }); + + it('should generate update plan when definition changed', async () => { + const { attributesEqual } = jest.requireMock('../../../../src/common/hashUtils'); + attributesEqual.mockReturnValue(false); + + const stateWithResource: StateFile = { + ...mockState, + resources: { + 'events.api_gateway': { + mode: 'managed', + region: 'cn-beijing', + definition: { groupName: 'test-gateway-old' }, + instances: [ + { + type: 'VOLCENGINE_APIGW_GROUP', + sid: 'volcengine:apigw:dev:gateway-123', + id: 'gateway-123', + }, + ], + lastUpdated: '2024-01-01T00:00:00Z', + }, + }, + }; + + const result = await generateApigwPlan( + mockContext, + stateWithResource, + [mockEvent], + 'test-service', + ); + + expect(result.items).toHaveLength(1); + expect(result.items[0].action).toBe('update'); + }); + + it('should handle error when fetching existing state gateway', async () => { + const { createVolcengineClient } = jest.requireMock( + '../../../../src/common/volcengineClient', + ); + createVolcengineClient.mockReturnValueOnce({ + apigw: { + getGateway: jest.fn().mockRejectedValue(new Error('Network error')), + findGatewayByName: jest.fn().mockResolvedValue(null), + }, + }); + + const stateWithResource: StateFile = { + ...mockState, + resources: { + 'events.api_gateway': { + mode: 'managed', + region: 'cn-beijing', + definition: { groupName: 'test-gateway' }, + instances: [ + { + type: 'VOLCENGINE_APIGW_GROUP', + sid: 'volcengine:apigw:dev:gateway-123', + id: 'gateway-123', + }, + ], + lastUpdated: '2024-01-01T00:00:00Z', + }, + }, + }; + + const result = await generateApigwPlan( + mockContext, + stateWithResource, + [mockEvent], + 'test-service', + ); + + expect(result.items).toHaveLength(1); + expect(result.items[0].action).toBe('create'); + }); + + it('should skip non-events resources in deletion filter', async () => { + const stateWithMixed: StateFile = { + ...mockState, + resources: { + 'events.api_gateway': { + mode: 'managed', + region: 'cn-beijing', + definition: { groupName: 'test-gateway' }, + instances: [ + { + type: 'VOLCENGINE_APIGW_GROUP', + sid: 'volcengine:apigw:dev:gateway-123', + id: 'gateway-123', + }, + ], + lastUpdated: '2024-01-01T00:00:00Z', + }, + 'functions.some_fn': { + mode: 'managed', + region: 'cn-beijing', + definition: { functionName: 'some-function' }, + instances: [{ sid: 'fn-sid', id: 'some-function', type: 'VOLCENGINE_VEFAAS_FUNCTION' }], + lastUpdated: '2024-01-01T00:00:00Z', + }, + }, + }; + + const { attributesEqual } = jest.requireMock('../../../../src/common/hashUtils'); + attributesEqual.mockReturnValue(true); + + const result = await generateApigwPlan( + mockContext, + stateWithMixed, + [mockEvent], + 'test-service', + ); + + const deleteItems = result.items.filter((i) => i.action === 'delete'); + expect(deleteItems).toHaveLength(0); + }); + it('should handle error when checking remote', async () => { const { createVolcengineClient } = jest.requireMock( '../../../../src/common/volcengineClient', diff --git a/tests/unit/stack/volcengineStack/apigwResource.test.ts b/tests/unit/stack/volcengineStack/apigwResource.test.ts index fcc4600..9024685 100644 --- a/tests/unit/stack/volcengineStack/apigwResource.test.ts +++ b/tests/unit/stack/volcengineStack/apigwResource.test.ts @@ -147,6 +147,51 @@ describe('apigwResource', () => { expect(result).toBeDefined(); }); + it('should create gateway via catch block when findGatewayByName throws', async () => { + const { createVolcengineClient } = jest.requireMock( + '../../../../src/common/volcengineClient', + ); + createVolcengineClient.mockReturnValueOnce({ + apigw: { + findGatewayByName: jest.fn().mockRejectedValue(new Error('Network error')), + createGateway: jest.fn().mockResolvedValue({ gatewayId: 'new-gateway-456' }), + getGateway: jest.fn().mockResolvedValue({ + gatewayId: 'new-gateway-456', + gatewayName: 'test-gateway', + protocol: 'HTTP', + status: 'Running', + }), + createApi: jest.fn().mockResolvedValue('api-123'), + getApi: jest + .fn() + .mockResolvedValue({ apiId: 'api-123', method: 'GET', path: '/api/test' }), + deployApi: jest.fn().mockResolvedValue(undefined), + bindDomain: jest.fn().mockResolvedValue(undefined), + }, + }); + + const result = await createApigwResource(mockContext, mockEvent, 'test-service', mockState); + + expect(result).toBeDefined(); + }); + + it('should throw when getGateway returns null after creation', async () => { + const { createVolcengineClient } = jest.requireMock( + '../../../../src/common/volcengineClient', + ); + createVolcengineClient.mockReturnValueOnce({ + apigw: { + findGatewayByName: jest.fn().mockResolvedValue(null), + createGateway: jest.fn().mockResolvedValue({ gatewayId: 'gateway-123' }), + getGateway: jest.fn().mockResolvedValue(null), + }, + }); + + await expect( + createApigwResource(mockContext, mockEvent, 'test-service', mockState), + ).rejects.toThrow('Failed to get API Gateway info after creation'); + }); + it('should reuse existing gateway if found', async () => { const { createVolcengineClient } = jest.requireMock( '../../../../src/common/volcengineClient', @@ -257,6 +302,134 @@ describe('apigwResource', () => { expect(result).toBeDefined(); }); + it('should fall back to createApigwResource when group instance not found in state', async () => { + const stateWithNoGroupInstance: StateFile = { + ...mockState, + resources: { + 'events.api_gateway': { + mode: 'managed', + region: 'cn-beijing', + definition: { groupName: 'test-gateway' }, + instances: [ + { + type: 'VOLCENGINE_APIGW_API', + sid: 'volcengine:apigw:dev:api-123', + id: 'api-123', + apiId: 'api-123', + }, + ], + lastUpdated: '2024-01-01T00:00:00Z', + }, + }, + }; + + const result = await updateApigwResource( + mockContext, + mockEvent, + 'test-service', + stateWithNoGroupInstance, + ); + expect(result).toBeDefined(); + }); + + it('should throw when getGateway returns null after update', async () => { + const { createVolcengineClient } = jest.requireMock( + '../../../../src/common/volcengineClient', + ); + createVolcengineClient.mockReturnValueOnce({ + apigw: { + updateGateway: jest.fn().mockResolvedValue(undefined), + getGateway: jest.fn().mockResolvedValue(null), + }, + }); + + const stateWithResource: StateFile = { + ...mockState, + resources: { + 'events.api_gateway': { + mode: 'managed', + region: 'cn-beijing', + definition: { groupName: 'test-gateway' }, + instances: [ + { + type: 'VOLCENGINE_APIGW_GROUP', + sid: 'volcengine:apigw:dev:gateway-123', + id: 'gateway-123', + gatewayId: 'gateway-123', + }, + ], + lastUpdated: '2024-01-01T00:00:00Z', + }, + }, + }; + + await expect( + updateApigwResource(mockContext, mockEvent, 'test-service', stateWithResource), + ).rejects.toThrow('Failed to get API Gateway info after update'); + }); + + it('should bind domain when event has domain in update', async () => { + const { createVolcengineClient } = jest.requireMock( + '../../../../src/common/volcengineClient', + ); + const mockBindDomain = jest.fn().mockResolvedValue(undefined); + createVolcengineClient.mockReturnValueOnce({ + apigw: { + updateGateway: jest.fn().mockResolvedValue(undefined), + getGateway: jest.fn().mockResolvedValue({ + gatewayId: 'gateway-123', + gatewayName: 'test-gateway', + }), + createApi: jest.fn().mockResolvedValue('api-123'), + getApi: jest + .fn() + .mockResolvedValue({ apiId: 'api-123', method: 'GET', path: '/api/test' }), + updateApi: jest.fn().mockResolvedValue(undefined), + deleteApi: jest.fn().mockResolvedValue(undefined), + deployApi: jest.fn().mockResolvedValue(undefined), + bindDomain: mockBindDomain, + unbindDomain: jest.fn().mockResolvedValue(undefined), + }, + }); + + const eventWithDomain: EventDomain = { + ...mockEvent, + domain: { + domain_name: 'api.example.com', + certificate_id: 'cert-123', + }, + }; + + const stateWithResource: StateFile = { + ...mockState, + resources: { + 'events.api_gateway': { + mode: 'managed', + region: 'cn-beijing', + definition: { groupName: 'test-gateway' }, + instances: [ + { + type: 'VOLCENGINE_APIGW_GROUP', + sid: 'volcengine:apigw:dev:gateway-123', + id: 'gateway-123', + gatewayId: 'gateway-123', + }, + ], + lastUpdated: '2024-01-01T00:00:00Z', + }, + }, + }; + + const result = await updateApigwResource( + mockContext, + eventWithDomain, + 'test-service', + stateWithResource, + ); + expect(result).toBeDefined(); + expect(mockBindDomain).toHaveBeenCalled(); + }); + it('should update existing resource', async () => { const stateWithResource: StateFile = { ...mockState, @@ -360,6 +533,74 @@ describe('apigwResource', () => { expect(result).toBeDefined(); }); + it('should call updateApi when existing API matches trigger', async () => { + const { createVolcengineClient } = jest.requireMock( + '../../../../src/common/volcengineClient', + ); + const mockUpdateApi = jest.fn().mockResolvedValue(undefined); + createVolcengineClient.mockReturnValueOnce({ + apigw: { + createGateway: jest.fn(), + getGateway: jest.fn().mockResolvedValue({ + gatewayId: 'gateway-123', + gatewayName: 'test-gateway', + }), + findGatewayByName: jest.fn(), + updateGateway: jest.fn().mockResolvedValue(undefined), + createApi: jest.fn().mockResolvedValue('new-api-123'), + getApi: jest.fn().mockResolvedValue({ + apiId: 'api-123', + apiName: 'test-gateway-dev-api-GET-api--test', + gatewayId: 'gateway-123', + method: 'GET', + path: '/api/test', + backendFunctionName: 'test-function', + }), + updateApi: mockUpdateApi, + deleteApi: jest.fn().mockResolvedValue(undefined), + deployApi: jest.fn().mockResolvedValue(undefined), + bindDomain: jest.fn().mockResolvedValue(undefined), + unbindDomain: jest.fn().mockResolvedValue(undefined), + }, + }); + + const stateWithMatchingApi: StateFile = { + ...mockState, + resources: { + 'events.api_gateway': { + mode: 'managed', + region: 'cn-beijing', + definition: { groupName: 'test-gateway' }, + instances: [ + { + type: 'VOLCENGINE_APIGW_GROUP', + sid: 'volcengine:apigw:dev:gateway-123', + id: 'gateway-123', + gatewayId: 'gateway-123', + }, + { + type: 'VOLCENGINE_APIGW_API', + sid: 'volcengine:apigw:dev:gateway-123/api-123', + id: 'api-123', + apiId: 'api-123', + apiName: 'test-gateway-dev-api-GET-api--test', + }, + ], + lastUpdated: '2024-01-01T00:00:00Z', + }, + }, + }; + + const result = await updateApigwResource( + mockContext, + mockEvent, + 'test-service', + stateWithMatchingApi, + ); + expect(result).toBeDefined(); + expect(mockUpdateApi).toHaveBeenCalledWith('api-123', expect.any(Object)); + }); + it('should delete unused APIs during update', async () => { const { createVolcengineClient } = jest.requireMock( '../../../../src/common/volcengineClient', @@ -485,6 +726,31 @@ describe('apigwResource', () => { }); describe('deleteApigwResource', () => { + it('should remove resource from state when group instance not found', async () => { + const stateWithNoGroup: StateFile = { + ...mockState, + resources: { + 'events.api_gateway': { + mode: 'managed', + region: 'cn-beijing', + definition: { groupName: 'test-gateway' }, + instances: [ + { + type: 'VOLCENGINE_APIGW_API', + sid: 'volcengine:apigw:dev:api-123', + id: 'api-123', + apiId: 'api-123', + }, + ], + lastUpdated: '2024-01-01T00:00:00Z', + }, + }, + }; + + const result = await deleteApigwResource(mockContext, 'events.api_gateway', stateWithNoGroup); + expect(result).toBeDefined(); + }); + it('should return state if resource not found', async () => { const result = await deleteApigwResource(mockContext, 'events.non_existent', mockState); expect(result).toEqual(mockState); diff --git a/tests/unit/stack/volcengineStack/tosPlanner.test.ts b/tests/unit/stack/volcengineStack/tosPlanner.test.ts index b33b73c..ad58f1c 100644 --- a/tests/unit/stack/volcengineStack/tosPlanner.test.ts +++ b/tests/unit/stack/volcengineStack/tosPlanner.test.ts @@ -282,5 +282,71 @@ describe('tosPlanner', () => { expect(result.items).toHaveLength(1); expect(result.items[0].action).toBe('create'); }); + + it('should return null websiteCodeHash when computeDirectoryHash throws', async () => { + const buckets: Array = [ + { + key: 'static_site', + name: 'test-bucket', + website: { + index: 'index.html', + code: './non-existent-dir', + error_page: '404.html', + error_code: 404, + }, + }, + ]; + + jest.spyOn(hashUtils, 'computeDirectoryHash').mockImplementation(() => { + throw new Error('Directory not found'); + }); + mockTosClient.tos.getBucket.mockResolvedValueOnce(null); + + const result = await generateBucketPlan(mockContext, mockState, buckets); + + expect(result.items).toHaveLength(1); + expect(result.items[0].action).toBe('create'); + }); + + it('should generate delete plan for buckets removed from desired set', async () => { + const buckets: Array = [ + { + key: 'active_bucket', + name: 'active-bucket', + }, + ]; + + const stateWithMultipleBuckets: StateFile = { + ...mockState, + resources: { + 'buckets.active_bucket': { + mode: 'managed', + region: 'cn-beijing', + definition: { bucketName: 'active-bucket' }, + instances: [{ sid: 'test-sid', id: 'active-bucket', type: 'VOLCENGINE_TOS_BUCKET' }], + lastUpdated: '2024-01-01T00:00:00Z', + }, + 'buckets.old_bucket': { + mode: 'managed', + region: 'cn-beijing', + definition: { bucketName: 'old-bucket' }, + instances: [{ sid: 'test-sid2', id: 'old-bucket', type: 'VOLCENGINE_TOS_BUCKET' }], + lastUpdated: '2024-01-01T00:00:00Z', + }, + }, + }; + + mockTosClient.tos.getBucket.mockResolvedValueOnce(null); + jest + .spyOn(stateManager, 'getAllResources') + .mockReturnValue(stateWithMultipleBuckets.resources); + jest.spyOn(stateManager, 'getResource').mockReturnValue(undefined); + + const result = await generateBucketPlan(mockContext, stateWithMultipleBuckets, buckets); + + const deleteItems = result.items.filter((i) => i.action === 'delete'); + expect(deleteItems).toHaveLength(1); + expect(deleteItems[0].logicalId).toBe('buckets.old_bucket'); + }); }); }); diff --git a/tests/unit/stack/volcengineStack/vefaasPlanner.test.ts b/tests/unit/stack/volcengineStack/vefaasPlanner.test.ts index 0538ade..95cfecf 100644 --- a/tests/unit/stack/volcengineStack/vefaasPlanner.test.ts +++ b/tests/unit/stack/volcengineStack/vefaasPlanner.test.ts @@ -96,6 +96,73 @@ describe('vefaasPlanner', () => { jest.spyOn(hashUtils, 'computeFileHash').mockReturnValue('test-hash'); }); + it('should not include non-function resources in delete plan', async () => { + const stateWithMixed: StateFile = { + ...mockState, + resources: { + 'functions.old_fn': { + mode: 'managed', + region: 'cn-beijing', + definition: { functionName: 'old-function' }, + instances: [ + { sid: 'test-sid', id: 'old-function', type: 'VOLCENGINE_VEFAAS_FUNCTION' }, + ], + lastUpdated: '2024-01-01T00:00:00Z', + }, + 'events.api_gateway': { + mode: 'managed', + region: 'cn-beijing', + definition: { groupName: 'test-gateway' }, + instances: [{ sid: 'event-sid', id: 'gateway-123', type: 'VOLCENGINE_APIGW_GROUP' }], + lastUpdated: '2024-01-01T00:00:00Z', + }, + }, + }; + + jest.spyOn(stateManager, 'getAllResources').mockReturnValue(stateWithMixed.resources); + + const result = await generateFunctionPlan(mockContext, stateWithMixed, []); + + expect(result.items).toHaveLength(1); + expect(result.items[0].logicalId).toBe('functions.old_fn'); + }); + + it('should generate delete plan for functions removed from desired set', async () => { + const stateWithMultipleFns: StateFile = { + ...mockState, + resources: { + 'functions.test_fn': { + mode: 'managed', + region: 'cn-beijing', + definition: { functionName: 'test-function' }, + instances: [ + { sid: 'test-sid', id: 'test-function', type: 'VOLCENGINE_VEFAAS_FUNCTION' }, + ], + lastUpdated: '2024-01-01T00:00:00Z', + }, + 'functions.old_fn': { + mode: 'managed', + region: 'cn-beijing', + definition: { functionName: 'old-function' }, + instances: [ + { sid: 'test-sid2', id: 'old-function', type: 'VOLCENGINE_VEFAAS_FUNCTION' }, + ], + lastUpdated: '2024-01-01T00:00:00Z', + }, + }, + }; + + mockVefaasClient.vefaas.getFunction.mockResolvedValueOnce(null); + jest.spyOn(stateManager, 'getAllResources').mockReturnValue(stateWithMultipleFns.resources); + jest.spyOn(stateManager, 'getResource').mockReturnValue(undefined); + + const result = await generateFunctionPlan(mockContext, stateWithMultipleFns, [mockFunction]); + + const deleteItems = result.items.filter((i) => i.action === 'delete'); + expect(deleteItems).toHaveLength(1); + expect(deleteItems[0].logicalId).toBe('functions.old_fn'); + }); + it('should return empty plan when functions array is empty', async () => { const result = await generateFunctionPlan(mockContext, mockState, []); diff --git a/tests/unit/stack/volcengineStack/vefaasResource.test.ts b/tests/unit/stack/volcengineStack/vefaasResource.test.ts index a8c7c51..007af7a 100644 --- a/tests/unit/stack/volcengineStack/vefaasResource.test.ts +++ b/tests/unit/stack/volcengineStack/vefaasResource.test.ts @@ -239,6 +239,48 @@ describe('vefaasResource', () => { expect(mockVefaasClient.tls.createProject).not.toHaveBeenCalled(); }); + it('should reuse existing IAM role and call updateRoleTrustPolicy when hasIamRole=true', async () => { + const stateWithIamRole: StateFile = { + ...mockState, + resources: { + 'functions.test_fn': { + mode: 'managed', + region: 'cn-beijing', + definition: {}, + instances: [ + { + type: 'VOLCENGINE_IAM_ROLE', + sid: 'volcengine-iam_role-dev-test-app-test-service-dev-role', + id: 'test-app-test-service-dev-role', + trn: 'trn:iam::123456:role/test-app-test-service-dev-role', + attributes: {}, + }, + ], + status: 'ready', + lastUpdated: '2024-01-01T00:00:00Z', + }, + }, + }; + + (getResource as jest.Mock).mockReturnValue(stateWithIamRole.resources['functions.test_fn']); + + mockVefaasClient.iam.updateRoleTrustPolicy.mockResolvedValueOnce(undefined); + mockVefaasClient.vefaas.createFunction.mockResolvedValueOnce(undefined); + mockVefaasClient.vefaas.getFunction.mockResolvedValueOnce({ + functionName: 'test-function', + functionId: 'func-123', + runtime: 'nodejs16', + handler: 'index.handler', + memoryMb: 128, + requestTimeout: 30, + }); + + await createResource(mockContext, mockFunction, stateWithIamRole); + + expect(mockVefaasClient.iam.updateRoleTrustPolicy).toHaveBeenCalled(); + expect(mockVefaasClient.iam.createRole).not.toHaveBeenCalled(); + }); + it('should create function with VPC config', async () => { const mockFunctionWithVpc: FunctionDomain = { ...mockFunction, @@ -328,6 +370,47 @@ describe('vefaasResource', () => { await expect(createResource(mockContext, mockFunction, mockState)).rejects.toThrow(); }); + it('should skip createFunction when tainted state has existing function on provider', async () => { + const taintedState: StateFile = { + ...mockState, + resources: { + 'functions.test_fn': { + mode: 'managed', + region: 'cn-beijing', + definition: {}, + instances: [], + status: 'tainted', + lastUpdated: '2024-01-01T00:00:00Z', + }, + }, + }; + + (getResource as jest.Mock).mockReturnValue(taintedState.resources['functions.test_fn']); + + mockVefaasClient.vefaas.getFunction + .mockResolvedValueOnce({ + functionName: 'test-function', + functionId: 'func-123', + runtime: 'nodejs16', + handler: 'index.handler', + memoryMb: 128, + requestTimeout: 30, + }) + .mockResolvedValueOnce({ + functionName: 'test-function', + functionId: 'func-123', + runtime: 'nodejs16', + handler: 'index.handler', + memoryMb: 128, + requestTimeout: 30, + }); + + await createResource(mockContext, mockFunction, taintedState); + + expect(mockVefaasClient.vefaas.createFunction).not.toHaveBeenCalled(); + expect(mockVefaasClient.vefaas.getFunction).toHaveBeenCalledWith('test-function'); + }); + it('should throw error when IAM role TRN is missing and accountId is not available', async () => { const contextWithoutAccountId: Context = { ...mockContext, @@ -825,6 +908,85 @@ describe('vefaasResource', () => { expect(removeResource).toHaveBeenCalled(); }); + it('should warn on unknown dependent resource type and continue', async () => { + const stateWithUnknown: StateFile = { + ...mockState, + resources: { + 'functions.test_fn': { + mode: 'managed', + region: 'cn-beijing', + definition: { functionName: 'test-function', codeHash: 'old-hash' }, + instances: [ + { + type: 'VOLCENGINE_VEFAAS_FUNCTION', + sid: 'volcengine-test-service-dev-test-function', + id: 'test-function', + functionName: 'test-function', + attributes: {}, + }, + { + type: 'VOLCENGINE_UNKNOWN_TYPE', + sid: 'volcengine-unknown-dev-something', + id: 'something', + attributes: {}, + }, + ], + lastUpdated: '2024-01-01T00:00:00Z', + }, + }, + }; + + mockVefaasClient.vefaas.deleteFunction.mockResolvedValueOnce(undefined); + (getResource as jest.Mock).mockReturnValue(stateWithUnknown.resources['functions.test_fn']); + + const { logger } = jest.requireMock('../../../../src/common/logger'); + + await deleteResource(mockContext, 'test-function', 'functions.test_fn', stateWithUnknown); + + expect(logger.warn).toHaveBeenCalled(); + expect(removeResource).toHaveBeenCalled(); + }); + + it('should log error when a delete operation throws during cleanup', async () => { + const stateWithIamRole: StateFile = { + ...mockState, + resources: { + 'functions.test_fn': { + mode: 'managed', + region: 'cn-beijing', + definition: { functionName: 'test-function', codeHash: 'old-hash' }, + instances: [ + { + type: 'VOLCENGINE_VEFAAS_FUNCTION', + sid: 'volcengine-test-service-dev-test-function', + id: 'test-function', + functionName: 'test-function', + attributes: {}, + }, + { + type: 'VOLCENGINE_IAM_ROLE', + sid: 'volcengine-iam_role-dev-role', + id: 'test-app-test-service-dev-role', + attributes: {}, + }, + ], + lastUpdated: '2024-01-01T00:00:00Z', + }, + }, + }; + + mockVefaasClient.vefaas.deleteFunction.mockResolvedValueOnce(undefined); + mockVefaasClient.iam.deleteRole.mockRejectedValueOnce(new Error('IAM delete failed')); + (getResource as jest.Mock).mockReturnValue(stateWithIamRole.resources['functions.test_fn']); + + const { logger } = jest.requireMock('../../../../src/common/logger'); + + await deleteResource(mockContext, 'test-function', 'functions.test_fn', stateWithIamRole); + + expect(logger.error).toHaveBeenCalled(); + expect(removeResource).toHaveBeenCalled(); + }); + it('should throw other errors', async () => { const error = new Error('Access denied') as Error & { code: string }; error.code = 'AccessDenied'; diff --git a/tests/unit/stack/volcengineStack/vefaasTypes.test.ts b/tests/unit/stack/volcengineStack/vefaasTypes.test.ts new file mode 100644 index 0000000..654a4d3 --- /dev/null +++ b/tests/unit/stack/volcengineStack/vefaasTypes.test.ts @@ -0,0 +1,177 @@ +import { + getTrustedServicesForFunction, + functionToVefaasConfig, + extractVefaasDefinition, + buildDefaultTrustPolicy, +} from '../../../../src/stack/volcengineStack/vefaasTypes'; +import type { FunctionDomain } from '../../../../src/types'; + +describe('vefaasTypes', () => { + const mockFn: FunctionDomain = { + key: 'test_fn', + name: 'test-function', + code: { + path: '/test/code.zip', + handler: 'index.handler', + runtime: 'nodejs16', + }, + memory: 256, + timeout: 30, + storage: {}, + }; + + describe('getTrustedServicesForFunction', () => { + it('should return only vefaas service when context has no iac', () => { + const context = {}; + + const result = getTrustedServicesForFunction(mockFn, context as never); + + expect(result).toEqual(['vefaas.volcengine.com']); + }); + + it('should return only vefaas service when context iac has no events', () => { + const context = { iac: {} }; + + const result = getTrustedServicesForFunction(mockFn, context as never); + + expect(result).toEqual(['vefaas.volcengine.com']); + }); + + it('should include apigateway service when a trigger backend matches', () => { + const context = { + iac: { + events: [ + { + triggers: [{ backend: '${functions.test_fn}' }], + }, + ], + }, + }; + + const result = getTrustedServicesForFunction(mockFn, context as never); + + expect(result).toEqual(['vefaas.volcengine.com', 'apigateway.volcengine.com']); + }); + + it('should return only vefaas when no trigger backend matches', () => { + const context = { + iac: { + events: [ + { + triggers: [{ backend: '${functions.other_fn}' }], + }, + ], + }, + }; + + const result = getTrustedServicesForFunction(mockFn, context as never); + + expect(result).toEqual(['vefaas.volcengine.com']); + }); + }); + + describe('functionToVefaasConfig', () => { + it('should convert function domain to vefaas config with minimal options', () => { + const result = functionToVefaasConfig(mockFn); + + expect(result.functionName).toBe('test-function'); + expect(result.runtime).toBe('nodejs16'); + expect(result.handler).toBe('index.handler'); + expect(result.memoryMb).toBe(256); + expect(result.requestTimeout).toBe(30); + }); + + it('should include role when provided', () => { + const result = functionToVefaasConfig(mockFn, { role: 'test-role' }); + + expect(result.role).toBe('test-role'); + }); + + it('should include vpcConfig when provided', () => { + const vpcConfig = { + vpcId: 'vpc-123', + subnetIds: ['subnet-1'], + securityGroupIds: ['sg-1'], + }; + const result = functionToVefaasConfig(mockFn, { vpcConfig }); + + expect(result.vpcConfig).toEqual(vpcConfig); + }); + + it('should not include tosMountConfig when not provided', () => { + const result = functionToVefaasConfig(mockFn, { role: 'test-role' }); + + expect(result.tosMountConfig).toBeUndefined(); + }); + + it('should include tosMountConfig when provided', () => { + const tosMountConfig = { bucketName: 'test-bucket', mountPath: '/mnt' }; + const result = functionToVefaasConfig(mockFn, { tosMountConfig }); + + expect(result.tosMountConfig).toEqual(tosMountConfig); + }); + + it('should include logConfig when provided', () => { + const logConfig = { project: 'test-project', topic: 'test-topic' }; + const result = functionToVefaasConfig(mockFn, { logConfig }); + + expect(result.logConfig).toEqual(logConfig); + }); + + it('should use defaults when memory and timeout not set', () => { + const fnWithoutDefaults: FunctionDomain = { + ...mockFn, + memory: undefined, + timeout: undefined, + }; + const result = functionToVefaasConfig(fnWithoutDefaults); + + expect(result.memoryMb).toBe(512); + expect(result.requestTimeout).toBe(60); + }); + }); + + describe('extractVefaasDefinition', () => { + it('should extract definition from config', () => { + const config = { + functionName: 'test-fn', + runtime: 'nodejs16' as never, + handler: 'index.handler', + memoryMb: 128, + requestTimeout: 30, + environmentVariables: { KEY: 'value' }, + }; + + const result = extractVefaasDefinition(config, 'hash-abc'); + + expect(result.functionName).toBe('test-fn'); + expect(result.codeHash).toBe('hash-abc'); + expect(result.environment).toEqual({ KEY: 'value' }); + }); + + it('should use empty object for environment when not set', () => { + const config = { + functionName: 'test-fn', + runtime: 'nodejs16' as never, + handler: 'index.handler', + memoryMb: 128, + requestTimeout: 30, + }; + + const result = extractVefaasDefinition(config, 'hash-abc'); + + expect(result.environment).toEqual({}); + }); + }); + + describe('buildDefaultTrustPolicy', () => { + it('should build trust policy for given services', () => { + const services = ['vefaas.volcengine.com']; + const result = buildDefaultTrustPolicy(services); + + expect(result.Statement[0].Effect).toBe('Allow'); + expect(result.Statement[0].Principal.Service).toEqual(services); + expect(result.Statement[0].Action).toContain('sts:AssumeRole'); + }); + }); +});