diff --git a/src/autocomplete/ResourceStateCompletionProvider.ts b/src/autocomplete/ResourceStateCompletionProvider.ts index bd7aa57..c2cf23e 100644 --- a/src/autocomplete/ResourceStateCompletionProvider.ts +++ b/src/autocomplete/ResourceStateCompletionProvider.ts @@ -53,12 +53,18 @@ export class ResourceStateCompletionProvider implements CompletionProvider { } log.info(`Retrieving resource details from AWS account with id: ${identifier} and type: ${resource.Type}`); - const resourceState = await this.resourceStateManager.getResource(resource.Type, identifier); - const properties = resourceState?.properties; - if (!properties) { + let properties: string; + try { + const result = await this.resourceStateManager.getResource(resource.Type, identifier); + if (!result.resource) { + return []; + } + properties = result.resource.properties; + } catch { log.info(`No resource found for id: ${identifier} and type: ${resource.Type}`); return []; } + const propertiesObj = JSON.parse(properties) as Record; this.applyTransformers(propertiesObj, schema); this.removeExistingProperties(propertiesObj, resource); diff --git a/src/handlers/ResourceHandler.ts b/src/handlers/ResourceHandler.ts index 6d667a3..26a3e82 100644 --- a/src/handlers/ResourceHandler.ts +++ b/src/handlers/ResourceHandler.ts @@ -136,6 +136,7 @@ export function searchResourceHandler( nextToken: result.resourceList.nextToken, } : undefined, + error: result.error, }; }; } diff --git a/src/resourceState/ResourceStateImporter.ts b/src/resourceState/ResourceStateImporter.ts index bf2ad74..a977500 100644 --- a/src/resourceState/ResourceStateImporter.ts +++ b/src/resourceState/ResourceStateImporter.ts @@ -16,6 +16,7 @@ import { CfnLspProviders } from '../server/CfnLspProviders'; import { LoggerFactory } from '../telemetry/LoggerFactory'; import { ScopedTelemetry } from '../telemetry/ScopedTelemetry'; import { Telemetry, Measure } from '../telemetry/TelemetryDecorator'; +import { extractErrorMessage } from '../utils/Errors'; import { getIndentationString } from '../utils/IndentationUtils'; import { ResourceStateManager } from './ResourceStateManager'; import { @@ -156,6 +157,7 @@ export class ResourceStateImporter { parentResourceType?: string, ): Promise<{ fetchedResourceStates: ResourceTemplateFormat[]; importResult: ResourceStateResult }> { const fetchedResourceStates: ResourceTemplateFormat[] = []; + const failureReasons: Record> = {}; const importResult: ResourceStateResult = { completionItem: undefined, failedImports: {}, @@ -175,40 +177,50 @@ export class ResourceStateImporter { } for (const resourceIdentifier of resourceSelection.resourceIdentifiers) { try { - const resourceState = await this.resourceStateManager.getResource(resourceType, resourceIdentifier); - if (resourceState) { - this.getOrCreate(importResult.successfulImports, resourceType, []).push(resourceIdentifier); - const logicalId = this.generateUniqueLogicalId( - resourceType, - resourceIdentifier, - syntaxTree, - generatedLogicalIds, - parentResourceType, - ); - generatedLogicalIds.add(logicalId); - fetchedResourceStates.push({ - [logicalId]: { - Type: resourceType, - DeletionPolicy: - purpose === ResourceStatePurpose.IMPORT ? DeletionPolicyOnImport : undefined, - Properties: this.applyTransformations( - resourceState.properties, - schema, - purpose, - logicalId, - ), - Metadata: await this.createMetadata(resourceIdentifier, purpose), - }, - }); - } else { + const result = await this.resourceStateManager.getResource(resourceType, resourceIdentifier); + if (!result.resource) { this.getOrCreate(importResult.failedImports, resourceType, []).push(resourceIdentifier); + if (result.error) { + failureReasons[resourceType] ??= {}; + failureReasons[resourceType][resourceIdentifier] = result.error; + } + continue; } + this.getOrCreate(importResult.successfulImports, resourceType, []).push(resourceIdentifier); + const logicalId = this.generateUniqueLogicalId( + resourceType, + resourceIdentifier, + syntaxTree, + generatedLogicalIds, + parentResourceType, + ); + generatedLogicalIds.add(logicalId); + fetchedResourceStates.push({ + [logicalId]: { + Type: resourceType, + DeletionPolicy: + purpose === ResourceStatePurpose.IMPORT ? DeletionPolicyOnImport : undefined, + Properties: this.applyTransformations( + result.resource.properties, + schema, + purpose, + logicalId, + ), + Metadata: await this.createMetadata(resourceIdentifier, purpose), + }, + }); } catch (error) { log.error(error, `Error importing resource state for ${resourceType} id: ${resourceIdentifier}`); this.getOrCreate(importResult.failedImports, resourceType, []).push(resourceIdentifier); + const errorMessage = extractErrorMessage(error); + failureReasons[resourceType] ??= {}; + failureReasons[resourceType][resourceIdentifier] = errorMessage; } } } + if (Object.keys(failureReasons).length > 0) { + importResult.failureReasons = failureReasons; + } return { fetchedResourceStates, importResult }; } diff --git a/src/resourceState/ResourceStateManager.ts b/src/resourceState/ResourceStateManager.ts index 36287fb..a2557ef 100644 --- a/src/resourceState/ResourceStateManager.ts +++ b/src/resourceState/ResourceStateManager.ts @@ -15,6 +15,7 @@ import { ScopedTelemetry } from '../telemetry/ScopedTelemetry'; import { Telemetry, Measure, Count } from '../telemetry/TelemetryDecorator'; import { isClientError } from '../utils/AwsErrorMapper'; import { Closeable } from '../utils/Closeable'; +import { extractErrorMessage } from '../utils/Errors'; import { NO_LIST_SUPPORT, REQUIRES_RESOURCE_MODEL } from './ListResourcesExclusionTypes'; import { ListResourcesResult, RefreshResourcesResult } from './ResourceStateTypes'; @@ -27,6 +28,8 @@ export type ResourceState = { createdTimestamp: DateTime; }; +export type GetResourceResult = { resource?: ResourceState; error?: string }; + type ResourceList = { typeName: string; resourceIdentifiers: string[]; @@ -60,12 +63,13 @@ export class ResourceStateManager implements SettingsConfigurable, Closeable { } @Measure({ name: 'getResource', captureErrorType: true }) - public async getResource(typeName: ResourceType, identifier: ResourceId): Promise { + public async getResource(typeName: ResourceType, identifier: ResourceId): Promise { const cachedResources = this.getResourceState(typeName, identifier); if (cachedResources) { this.telemetry.count('state.hit', 1); - return cachedResources; + return { resource: cachedResources }; } + this.telemetry.count('state.miss', 1); let output: GetResourceCommandOutput | undefined = undefined; @@ -75,12 +79,12 @@ export class ResourceStateManager implements SettingsConfigurable, Closeable { } catch (error) { if (error instanceof ResourceNotFoundException) { log.info(`No resource found for type ${typeName} and identifier "${identifier}"`); - return; - } - if (isClientError(error)) { + return { error: extractErrorMessage(error) }; + } else if (isClientError(error)) { log.info(`Client error for type ${typeName} and identifier "${identifier}"`); - return; + return { error: extractErrorMessage(error) }; } + log.error(error, `CCAPI GetResource failed for type ${typeName} and identifier "${identifier}"`); throw error; } @@ -88,7 +92,7 @@ export class ResourceStateManager implements SettingsConfigurable, Closeable { log.error( `GetResource output is missing required fields for type ${typeName} with identifier "${identifier}"`, ); - return; + return { error: 'GetResource output is missing required fields' }; } const value: ResourceState = { @@ -99,7 +103,7 @@ export class ResourceStateManager implements SettingsConfigurable, Closeable { }; this.storeResourceState(typeName, identifier, value); - return value; + return { resource: value }; } @Measure({ name: 'listResources' }) @@ -135,12 +139,18 @@ export class ResourceStateManager implements SettingsConfigurable, Closeable { public async searchResourceByIdentifier( typeName: string, identifier: string, - ): Promise<{ found: boolean; resourceList?: ResourceList }> { - const resource = await this.getResource(typeName, identifier); - if (!resource) { + ): Promise<{ found: boolean; resourceList?: ResourceList; error?: string }> { + let result: GetResourceResult; + try { + result = await this.getResource(typeName, identifier); + } catch { return { found: false }; } + if (!result.resource) { + return { found: false, error: result.error }; + } + // Add to cache const cached = this.resourceListMap.get(typeName); if (cached && !cached.resourceIdentifiers.includes(identifier)) { diff --git a/src/resourceState/ResourceStateTypes.ts b/src/resourceState/ResourceStateTypes.ts index 0bb9e63..923b0e6 100644 --- a/src/resourceState/ResourceStateTypes.ts +++ b/src/resourceState/ResourceStateTypes.ts @@ -38,6 +38,7 @@ export interface ResourceStateResult { completionItem?: CompletionItem; successfulImports: Record; failedImports: Record; + failureReasons?: Record>; warning?: string; } @@ -88,6 +89,7 @@ export type SearchResourceParams = { export type SearchResourceResult = { found: boolean; resource?: ResourceSummary; + error?: string; }; export const SearchResourceRequest = new RequestType( diff --git a/src/schema/ResourceSchema.ts b/src/schema/ResourceSchema.ts index 9515463..6ff3e2c 100644 --- a/src/schema/ResourceSchema.ts +++ b/src/schema/ResourceSchema.ts @@ -170,7 +170,7 @@ export class ResourceSchema { } const parts = path.split('/'); - let current: any = this as unknown as any; // eslint-disable-line @typescript-eslint/no-unnecessary-type-assertion + let current: any = this as any; for (const part of parts) { if (!current || typeof current !== 'object') { diff --git a/tst/unit/autocomplete/ResourceStateCompletionProvider.test.ts b/tst/unit/autocomplete/ResourceStateCompletionProvider.test.ts index deb482d..0e112bc 100644 --- a/tst/unit/autocomplete/ResourceStateCompletionProvider.test.ts +++ b/tst/unit/autocomplete/ResourceStateCompletionProvider.test.ts @@ -115,7 +115,7 @@ describe('ResourceStateCompletionProvider', () => { }); mockComponents.schemaRetriever.getDefault.returns(s3Schemas); - mockComponents.resourceStateManager.getResource.resolves(undefined); + mockComponents.resourceStateManager.getResource.resolves({ error: 'Resource not found' }); const result = await provider.getCompletions(context, mockYamlParams); @@ -132,12 +132,7 @@ describe('ResourceStateCompletionProvider', () => { }); mockComponents.schemaRetriever.getDefault.returns(s3Schemas); - mockComponents.resourceStateManager.getResource.resolves({ - typeName: 'AWS::S3::Bucket', - identifier: 'test', - properties: '', - createdTimestamp: new Date() as any, - }); + mockComponents.resourceStateManager.getResource.rejects(new Error('Invalid resource state')); const result = await provider.getCompletions(context, mockYamlParams); @@ -157,14 +152,16 @@ describe('ResourceStateCompletionProvider', () => { emptySchemas.schemas.set('Custom::Type', customTypeSchema); mockComponents.schemaRetriever.getDefault.returns(emptySchemas); mockComponents.resourceStateManager.getResource.resolves({ - typeName: 'Custom::Type', - identifier: 'test', - properties: JSON.stringify({ - BucketName: 'test', - VersioningConfiguration: { Status: 'Enabled' }, - ExistingProp: 'value', - }), - createdTimestamp: new Date() as any, + resource: { + typeName: 'Custom::Type', + identifier: 'test', + properties: JSON.stringify({ + BucketName: 'test', + VersioningConfiguration: { Status: 'Enabled' }, + ExistingProp: 'value', + }), + createdTimestamp: new Date() as any, + }, }); const result = await provider.getCompletions(context, mockYamlParams); @@ -192,13 +189,15 @@ describe('ResourceStateCompletionProvider', () => { emptySchemas.schemas.set('Custom::Type', customTypeSchema); mockComponents.schemaRetriever.getDefault.returns(emptySchemas); mockComponents.resourceStateManager.getResource.resolves({ - typeName: 'Custom::Type', - identifier: 'test', - properties: JSON.stringify({ - BucketName: 'test', - VersioningConfiguration: { Status: 'Enabled' }, - }), - createdTimestamp: new Date() as any, + resource: { + typeName: 'Custom::Type', + identifier: 'test', + properties: JSON.stringify({ + BucketName: 'test', + VersioningConfiguration: { Status: 'Enabled' }, + }), + createdTimestamp: new Date() as any, + }, }); const result = await provider.getCompletions(context, mockYamlParams); @@ -273,10 +272,12 @@ describe('ResourceStateCompletionProvider', () => { }); mockComponents.resourceStateManager.getResource.resolves({ - typeName: 'AWS::IAM::Role', - identifier: 'Admin', - properties: `{"Path":"/","ManagedPolicyArns":["arn:aws:iam::aws:policy/AdministratorAccess"],"MaxSessionDuration":43200,"RoleName":"Admin","AssumeRolePolicyDocument":{"Version":"2012-10-17","Statement":[{"Condition":{"StringEquals":{"sts:ExternalId":"IsengardExternalIdAKj8duTfSqL6"}},"Action":"sts:AssumeRole","Effect":"Allow","Principal":{"AWS":"arn:aws:iam::727820809195:root"},"Sid":""}]},"Arn":"arn:aws:iam::783764615233:role/Admin","RoleId":"AROA3M7AC6BAWIZG2LLQY"}`, - createdTimestamp: DateTime.now(), + resource: { + typeName: 'AWS::IAM::Role', + identifier: 'Admin', + properties: `{"Path":"/","ManagedPolicyArns":["arn:aws:iam::aws:policy/AdministratorAccess"],"MaxSessionDuration":43200,"RoleName":"Admin","AssumeRolePolicyDocument":{"Version":"2012-10-17","Statement":[{"Condition":{"StringEquals":{"sts:ExternalId":"IsengardExternalIdAKj8duTfSqL6"}},"Action":"sts:AssumeRole","Effect":"Allow","Principal":{"AWS":"arn:aws:iam::727820809195:root"},"Sid":""}]},"Arn":"arn:aws:iam::783764615233:role/Admin","RoleId":"AROA3M7AC6BAWIZG2LLQY"}`, + createdTimestamp: DateTime.now(), + }, }); const result = await provider.getCompletions(context, mockJsonParams); @@ -312,10 +313,12 @@ describe('ResourceStateCompletionProvider', () => { }); mockComponents.resourceStateManager.getResource.resolves({ - typeName: 'AWS::IAM::Role', - identifier: 'Admin', - properties: `{"Path":"/","ManagedPolicyArns":["arn:aws:iam::aws:policy/AdministratorAccess"],"MaxSessionDuration":43200,"RoleName":"Admin","AssumeRolePolicyDocument":{"Version":"2012-10-17","Statement":[{"Condition":{"StringEquals":{"sts:ExternalId":"IsengardExternalIdAKj8duTfSqL6"}},"Action":"sts:AssumeRole","Effect":"Allow","Principal":{"AWS":"arn:aws:iam::727820809195:root"},"Sid":""}]},"Arn":"arn:aws:iam::783764615233:role/Admin","RoleId":"AROA3M7AC6BAWIZG2LLQY"}`, - createdTimestamp: DateTime.now(), + resource: { + typeName: 'AWS::IAM::Role', + identifier: 'Admin', + properties: `{"Path":"/","ManagedPolicyArns":["arn:aws:iam::aws:policy/AdministratorAccess"],"MaxSessionDuration":43200,"RoleName":"Admin","AssumeRolePolicyDocument":{"Version":"2012-10-17","Statement":[{"Condition":{"StringEquals":{"sts:ExternalId":"IsengardExternalIdAKj8duTfSqL6"}},"Action":"sts:AssumeRole","Effect":"Allow","Principal":{"AWS":"arn:aws:iam::727820809195:root"},"Sid":""}]},"Arn":"arn:aws:iam::783764615233:role/Admin","RoleId":"AROA3M7AC6BAWIZG2LLQY"}`, + createdTimestamp: DateTime.now(), + }, }); const result = await provider.getCompletions(context, mockYamlParams); @@ -464,13 +467,15 @@ describe('ResourceStateCompletionProvider', () => { emptySchemas.schemas.set('Custom::Type', customTypeSchema); mockComponents.schemaRetriever.getDefault.returns(emptySchemas); mockComponents.resourceStateManager.getResource.resolves({ - typeName: 'Custom::Type', - identifier: 'test', - properties: JSON.stringify({ - BucketName: 'test', - VersioningConfiguration: { Status: 'Enabled' }, - }), - createdTimestamp: new Date() as any, + resource: { + typeName: 'Custom::Type', + identifier: 'test', + properties: JSON.stringify({ + BucketName: 'test', + VersioningConfiguration: { Status: 'Enabled' }, + }), + createdTimestamp: new Date() as any, + }, }); const result = await provider.getCompletions(context, mockYamlParams); @@ -495,13 +500,15 @@ describe('ResourceStateCompletionProvider', () => { mockComponents.schemaRetriever.getDefault.returns(emptySchemas); mockComponents.documentManager.getLine.returns('"",'); mockComponents.resourceStateManager.getResource.resolves({ - typeName: 'Custom::Type', - identifier: 'test', - properties: JSON.stringify({ - BucketName: 'test', - VersioningConfiguration: { Status: 'Enabled' }, - }), - createdTimestamp: new Date() as any, + resource: { + typeName: 'Custom::Type', + identifier: 'test', + properties: JSON.stringify({ + BucketName: 'test', + VersioningConfiguration: { Status: 'Enabled' }, + }), + createdTimestamp: new Date() as any, + }, }); const result = await provider.getCompletions(context, mockJsonParams); diff --git a/tst/unit/resourceState/ResourceStateImporter.test.ts b/tst/unit/resourceState/ResourceStateImporter.test.ts index b526750..12eb610 100644 --- a/tst/unit/resourceState/ResourceStateImporter.test.ts +++ b/tst/unit/resourceState/ResourceStateImporter.test.ts @@ -92,7 +92,7 @@ describe('ResourceStateImporter', () => { }, ]; - mockResourceStateManager.getResource.mockResolvedValue(mockResource); + mockResourceStateManager.getResource.mockResolvedValue({ resource: mockResource }); const params: ResourceStateParams = { resourceSelections, @@ -136,7 +136,7 @@ describe('ResourceStateImporter', () => { }, ]; - mockResourceStateManager.getResource.mockResolvedValue(mockResource); + mockResourceStateManager.getResource.mockResolvedValue({ resource: mockResource }); const params: ResourceStateParams = { resourceSelections, @@ -203,6 +203,9 @@ describe('ResourceStateImporter', () => { createAndRegisterDocument(uri, scenario.initialContent, scenario.documentType); + // Mock getResource to throw an error + mockResourceStateManager.getResource.mockRejectedValue(new Error('Resource not found')); + const params: ResourceStateParams = { resourceSelections: [{ resourceType: 'AWS::S3::Bucket', resourceIdentifiers: ['test-bucket'] }], textDocument: { uri }, @@ -216,6 +219,79 @@ describe('ResourceStateImporter', () => { expect(Object.keys(result.failedImports)).toHaveLength(1); }); + it('should populate failureReasons when resource import fails', async () => { + const uri = 'test://test-failure-reasons.template'; + const scenario = TestScenarios[0]; + + createAndRegisterDocument(uri, scenario.initialContent, scenario.documentType); + + mockResourceStateManager.getResource.mockResolvedValue({ error: 'Access denied' }); + + const params: ResourceStateParams = { + resourceSelections: [{ resourceType: 'AWS::S3::Bucket', resourceIdentifiers: ['my-bucket'] }], + textDocument: { uri }, + purpose: ResourceStatePurpose.IMPORT, + }; + + const result = await importer.importResourceState(params); + + expect(result.failureReasons).toBeDefined(); + expect(result.failureReasons!['AWS::S3::Bucket']['my-bucket']).toBe('Access denied'); + }); + + it('should not include failureReasons when all imports succeed', async () => { + const uri = 'test://test-no-failure-reasons.template'; + const scenario = TestScenarios[0]; + + createAndRegisterDocument(uri, scenario.initialContent, scenario.documentType); + + const mockResource = createMockResourceState('AWS::S3::Bucket'); + mockResourceStateManager.getResource.mockResolvedValue({ resource: mockResource }); + + const params: ResourceStateParams = { + resourceSelections: [ + { resourceType: 'AWS::S3::Bucket', resourceIdentifiers: [mockResource.identifier] }, + ], + textDocument: { uri }, + purpose: ResourceStatePurpose.IMPORT, + }; + + const result = await importer.importResourceState(params); + + expect(result.failureReasons).toBeUndefined(); + }); + + it('should populate failureReasons per resource type and identifier', async () => { + const uri = 'test://test-multi-failure-reasons.template'; + const scenario = TestScenarios[0]; + + createAndRegisterDocument(uri, scenario.initialContent, scenario.documentType); + + const mockResource = createMockResourceState('AWS::S3::Bucket'); + mockResourceStateManager.getResource + .mockResolvedValueOnce({ resource: mockResource }) + .mockResolvedValueOnce({ error: 'Not found' }) + .mockResolvedValueOnce({ error: 'Timeout' }); + + const params: ResourceStateParams = { + resourceSelections: [ + { + resourceType: 'AWS::S3::Bucket', + resourceIdentifiers: [mockResource.identifier, 'bad-bucket', 'timeout-bucket'], + }, + ], + textDocument: { uri }, + purpose: ResourceStatePurpose.IMPORT, + }; + + const result = await importer.importResourceState(params); + + expect(result.successfulImports['AWS::S3::Bucket']).toContain(mockResource.identifier); + expect(result.failureReasons).toBeDefined(); + expect(result.failureReasons!['AWS::S3::Bucket']['bad-bucket']).toBe('Not found'); + expect(result.failureReasons!['AWS::S3::Bucket']['timeout-bucket']).toBe('Timeout'); + }); + it('should include warning when importing managed resources', async () => { const uri = 'test://test-managed-resources.template'; const scenario = TestScenarios[0]; @@ -230,7 +306,7 @@ describe('ResourceStateImporter', () => { }); const mockResource = createMockResourceState('AWS::S3::Bucket'); - mockResourceStateManager.getResource.mockResolvedValue(mockResource); + mockResourceStateManager.getResource.mockResolvedValue({ resource: mockResource }); const params: ResourceStateParams = { resourceSelections: [ @@ -264,7 +340,7 @@ describe('ResourceStateImporter', () => { }); const mockResource = createMockResourceState('AWS::S3::Bucket'); - mockResourceStateManager.getResource.mockResolvedValue(mockResource); + mockResourceStateManager.getResource.mockResolvedValue({ resource: mockResource }); const params: ResourceStateParams = { resourceSelections: [ @@ -294,7 +370,7 @@ describe('ResourceStateImporter', () => { }); const mockResource = createMockResourceState('AWS::S3::Bucket'); - mockResourceStateManager.getResource.mockResolvedValue(mockResource); + mockResourceStateManager.getResource.mockResolvedValue({ resource: mockResource }); const params: ResourceStateParams = { resourceSelections: [ @@ -344,7 +420,7 @@ describe('ResourceStateImporter', () => { }, ]; - mockResourceStateManager.getResource.mockResolvedValue(mockResource); + mockResourceStateManager.getResource.mockResolvedValue({ resource: mockResource }); const params: ResourceStateParams = { resourceSelections, @@ -383,7 +459,10 @@ describe('ResourceStateImporter', () => { }, ]; - mockResourceStateManager.getResource.mockResolvedValue(mockResource1); + mockResourceStateManager.getResource + .mockResolvedValueOnce({ resource: mockResource1 }) + .mockResolvedValueOnce({ resource: mockResource2 }) + .mockResolvedValueOnce({ resource: mockResource3 }); const params: ResourceStateParams = { resourceSelections, diff --git a/tst/unit/resourceState/ResourceStateManager.test.ts b/tst/unit/resourceState/ResourceStateManager.test.ts index 0c038c9..6017bca 100644 --- a/tst/unit/resourceState/ResourceStateManager.test.ts +++ b/tst/unit/resourceState/ResourceStateManager.test.ts @@ -47,7 +47,7 @@ describe('ResourceStateManager', () => { const result = await manager.getResource('AWS::S3::Bucket', 'my-bucket'); - expect(result?.properties).toEqual('{"BucketName": "my-bucket"}'); + expect(result?.resource?.properties).toEqual('{"BucketName": "my-bucket"}'); await manager.getResource('AWS::S3::Bucket', 'my-bucket'); expect(mockCcapiService.getResource).toHaveBeenCalledOnce(); }); @@ -66,14 +66,16 @@ describe('ResourceStateManager', () => { const result = await manager.getResource('AWS::S3::Bucket', 'my-bucket'); expect(result).toEqual({ - typeName: 'AWS::S3::Bucket', - identifier: 'my-bucket', - properties: '{"BucketName": "my-bucket"}', - createdTimestamp: expect.any(DateTime), + resource: { + typeName: 'AWS::S3::Bucket', + identifier: 'my-bucket', + properties: '{"BucketName": "my-bucket"}', + createdTimestamp: expect.any(DateTime), + }, }); }); - it('should handle ResourceNotFoundException', async () => { + it('should return error for ResourceNotFoundException', async () => { const error = new ResourceNotFoundException({ message: 'Resource not found', $metadata: { httpStatusCode: 404 }, @@ -81,27 +83,30 @@ describe('ResourceStateManager', () => { vi.mocked(mockCcapiService.getResource).mockRejectedValue(error); const result = await manager.getResource('AWS::S3::Bucket', 'nonexistent'); - - expect(result).toBeUndefined(); + expect(result.resource).toBeUndefined(); + expect(result.error).toContain('Resource not found'); }); - it('should throw on server errors', async () => { + it('should rethrow other errors', async () => { const error = new Error('Service error'); vi.mocked(mockCcapiService.getResource).mockRejectedValue(error); await expect(manager.getResource('AWS::S3::Bucket', 'my-bucket')).rejects.toThrow('Service error'); }); - it('should return undefined for client errors', async () => { - const error = { name: 'AccessDeniedException', $metadata: { httpStatusCode: 403 }, message: 'Denied' }; + it('should return error for AccessDeniedException', async () => { + const error = new Error( + 'User: arn:aws:sts::123456789012:assumed-role/Limited/user is not authorized to perform: cloudformation:GetResource on resource: arn:aws:cloudformation:us-east-1:123456789012:resource/*', + ); + error.name = 'AccessDeniedException'; vi.mocked(mockCcapiService.getResource).mockRejectedValue(error); const result = await manager.getResource('AWS::S3::Bucket', 'my-bucket'); - - expect(result).toBeUndefined(); + expect(result.resource).toBeUndefined(); + expect(result.error).toContain('not authorized to perform'); }); - it('should handle missing required fields in output', async () => { + it('should return error for missing required fields', async () => { const mockOutput: GetResourceCommandOutput = { TypeName: 'AWS::S3::Bucket', ResourceDescription: { @@ -113,8 +118,8 @@ describe('ResourceStateManager', () => { vi.mocked(mockCcapiService.getResource).mockResolvedValue(mockOutput); const result = await manager.getResource('AWS::S3::Bucket', 'my-bucket'); - - expect(result).toBeUndefined(); + expect(result.resource).toBeUndefined(); + expect(result.error).toBe('GetResource output is missing required fields'); }); });