From 355e36e44b198cb1c2a1abbe1c8e8452cad019a6 Mon Sep 17 00:00:00 2001 From: gemammercado Date: Tue, 24 Feb 2026 10:52:00 -0500 Subject: [PATCH 01/10] add error message to resource importer result and update tests --- .../ResourceStateCompletionProvider.ts | 4 +- src/resourceState/ResourceStateImporter.ts | 16 ++- src/resourceState/ResourceStateManager.ts | 23 ++--- src/resourceState/ResourceStateTypes.ts | 1 + .../ResourceStateCompletionProvider.test.ts | 98 +++++++++++-------- .../ResourceStateImporter.test.ts | 17 ++-- .../ResourceStateManager.test.ts | 30 ++++-- 7 files changed, 116 insertions(+), 73 deletions(-) diff --git a/src/autocomplete/ResourceStateCompletionProvider.ts b/src/autocomplete/ResourceStateCompletionProvider.ts index bd7aa57..4dc2d6f 100644 --- a/src/autocomplete/ResourceStateCompletionProvider.ts +++ b/src/autocomplete/ResourceStateCompletionProvider.ts @@ -53,8 +53,8 @@ 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; + const result = await this.resourceStateManager.getResource(resource.Type, identifier); + const properties = result.resource?.properties; if (!properties) { log.info(`No resource found for id: ${identifier} and type: ${resource.Type}`); return []; diff --git a/src/resourceState/ResourceStateImporter.ts b/src/resourceState/ResourceStateImporter.ts index bf2ad74..d658d0a 100644 --- a/src/resourceState/ResourceStateImporter.ts +++ b/src/resourceState/ResourceStateImporter.ts @@ -160,6 +160,7 @@ export class ResourceStateImporter { completionItem: undefined, failedImports: {}, successfulImports: {}, + failureReasons: {}, }; const generatedLogicalIds = new Set(); @@ -175,8 +176,8 @@ export class ResourceStateImporter { } for (const resourceIdentifier of resourceSelection.resourceIdentifiers) { try { - const resourceState = await this.resourceStateManager.getResource(resourceType, resourceIdentifier); - if (resourceState) { + const result = await this.resourceStateManager.getResource(resourceType, resourceIdentifier); + if (result.resource) { this.getOrCreate(importResult.successfulImports, resourceType, []).push(resourceIdentifier); const logicalId = this.generateUniqueLogicalId( resourceType, @@ -192,7 +193,7 @@ export class ResourceStateImporter { DeletionPolicy: purpose === ResourceStatePurpose.IMPORT ? DeletionPolicyOnImport : undefined, Properties: this.applyTransformations( - resourceState.properties, + result.resource.properties, schema, purpose, logicalId, @@ -202,10 +203,19 @@ export class ResourceStateImporter { }); } else { this.getOrCreate(importResult.failedImports, resourceType, []).push(resourceIdentifier); + if (result.error) { + importResult.failureReasons ??= {}; + importResult.failureReasons[resourceType] ??= {}; + importResult.failureReasons[resourceType][resourceIdentifier] = result.error; + } } } catch (error) { log.error(error, `Error importing resource state for ${resourceType} id: ${resourceIdentifier}`); this.getOrCreate(importResult.failedImports, resourceType, []).push(resourceIdentifier); + const errorMessage = error instanceof Error ? error.message : String(error); + importResult.failureReasons ??= {}; + importResult.failureReasons[resourceType] ??= {}; + importResult.failureReasons[resourceType][resourceIdentifier] = errorMessage; } } } diff --git a/src/resourceState/ResourceStateManager.ts b/src/resourceState/ResourceStateManager.ts index 36287fb..3f85d25 100644 --- a/src/resourceState/ResourceStateManager.ts +++ b/src/resourceState/ResourceStateManager.ts @@ -17,6 +17,7 @@ import { isClientError } from '../utils/AwsErrorMapper'; import { Closeable } from '../utils/Closeable'; import { NO_LIST_SUPPORT, REQUIRES_RESOURCE_MODEL } from './ListResourcesExclusionTypes'; import { ListResourcesResult, RefreshResourcesResult } from './ResourceStateTypes'; +import { cached } from 'zod/v4/core/util.cjs'; const log = LoggerFactory.getLogger('ResourceStateManager'); @@ -59,13 +60,14 @@ export class ResourceStateManager implements SettingsConfigurable, Closeable { this.initializeCounters(); } - @Measure({ name: 'getResource', captureErrorType: true }) - public async getResource(typeName: ResourceType, identifier: ResourceId): Promise { + @Measure({ name: 'getResource' }) + public async getResource(typeName: ResourceType, identifier: ResourceId): Promise { const cachedResources = this.getResourceState(typeName, identifier); if (cachedResources) { this.telemetry.count('state.hit', 1); return cachedResources; } + this.telemetry.count('state.miss', 1); let output: GetResourceCommandOutput | undefined = undefined; @@ -75,20 +77,18 @@ 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)) { + } else if (isClientError(error)) { log.info(`Client error for type ${typeName} and identifier "${identifier}"`); - return; + } else { + log.error(error, `CCAPI GetResource failed for type ${typeName} and identifier "${identifier}"`); } throw error; } if (!output?.TypeName || !output?.ResourceDescription?.Identifier || !output?.ResourceDescription?.Properties) { - log.error( + throw new Error ( `GetResource output is missing required fields for type ${typeName} with identifier "${identifier}"`, ); - return; } const value: ResourceState = { @@ -136,11 +136,12 @@ export class ResourceStateManager implements SettingsConfigurable, Closeable { typeName: string, identifier: string, ): Promise<{ found: boolean; resourceList?: ResourceList }> { - const resource = await this.getResource(typeName, identifier); - if (!resource) { + try { + await this.getResource(typeName, identifier); + } catch { return { found: false }; } - + // 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..a8f0510 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; } diff --git a/tst/unit/autocomplete/ResourceStateCompletionProvider.test.ts b/tst/unit/autocomplete/ResourceStateCompletionProvider.test.ts index deb482d..d4e277d 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({}); const result = await provider.getCompletions(context, mockYamlParams); @@ -133,10 +133,12 @@ describe('ResourceStateCompletionProvider', () => { mockComponents.schemaRetriever.getDefault.returns(s3Schemas); mockComponents.resourceStateManager.getResource.resolves({ - typeName: 'AWS::S3::Bucket', - identifier: 'test', - properties: '', - createdTimestamp: new Date() as any, + resource: { + typeName: 'AWS::S3::Bucket', + identifier: 'test', + properties: '', + createdTimestamp: new Date() as any, + }, }); const result = await provider.getCompletions(context, mockYamlParams); @@ -157,14 +159,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 +196,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 +279,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 +320,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 +474,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 +507,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..9c65075 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, @@ -230,7 +230,7 @@ describe('ResourceStateImporter', () => { }); const mockResource = createMockResourceState('AWS::S3::Bucket'); - mockResourceStateManager.getResource.mockResolvedValue(mockResource); + mockResourceStateManager.getResource.mockResolvedValue({ resource: mockResource }); const params: ResourceStateParams = { resourceSelections: [ @@ -264,7 +264,7 @@ describe('ResourceStateImporter', () => { }); const mockResource = createMockResourceState('AWS::S3::Bucket'); - mockResourceStateManager.getResource.mockResolvedValue(mockResource); + mockResourceStateManager.getResource.mockResolvedValue({ resource: mockResource }); const params: ResourceStateParams = { resourceSelections: [ @@ -294,7 +294,7 @@ describe('ResourceStateImporter', () => { }); const mockResource = createMockResourceState('AWS::S3::Bucket'); - mockResourceStateManager.getResource.mockResolvedValue(mockResource); + mockResourceStateManager.getResource.mockResolvedValue({ resource: mockResource }); const params: ResourceStateParams = { resourceSelections: [ @@ -344,7 +344,7 @@ describe('ResourceStateImporter', () => { }, ]; - mockResourceStateManager.getResource.mockResolvedValue(mockResource); + mockResourceStateManager.getResource.mockResolvedValue({ resource: mockResource }); const params: ResourceStateParams = { resourceSelections, @@ -383,7 +383,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..6aa630c 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,10 +66,12 @@ 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), + }, }); }); @@ -82,7 +84,7 @@ describe('ResourceStateManager', () => { const result = await manager.getResource('AWS::S3::Bucket', 'nonexistent'); - expect(result).toBeUndefined(); + expect(result.error).toBe('Resource not found'); }); it('should throw on server errors', async () => { @@ -98,7 +100,19 @@ describe('ResourceStateManager', () => { const result = await manager.getResource('AWS::S3::Bucket', 'my-bucket'); - expect(result).toBeUndefined(); + expect(result.error).toBe('Service error'); + }); + + it('should handle AccessDeniedException with user-friendly message', 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.error).toBe(error.message); }); it('should handle missing required fields in output', async () => { @@ -114,7 +128,7 @@ describe('ResourceStateManager', () => { const result = await manager.getResource('AWS::S3::Bucket', 'my-bucket'); - expect(result).toBeUndefined(); + expect(result.error).toBe('GetResource output is missing required fields'); }); }); From ec9ef4c365082596529b2bbe4e3fde76f01bd963 Mon Sep 17 00:00:00 2001 From: gemammercado Date: Fri, 6 Mar 2026 12:16:31 -0500 Subject: [PATCH 02/10] address feedback --- .../ResourceStateCompletionProvider.ts | 9 +- src/resourceState/ResourceStateImporter.ts | 52 ++++------ .../ResourceStateCompletionProvider.test.ts | 97 ++++++++----------- .../ResourceStateImporter.test.ts | 21 ++-- .../ResourceStateManager.test.ts | 43 +++----- 5 files changed, 90 insertions(+), 132 deletions(-) diff --git a/src/autocomplete/ResourceStateCompletionProvider.ts b/src/autocomplete/ResourceStateCompletionProvider.ts index 4dc2d6f..f238395 100644 --- a/src/autocomplete/ResourceStateCompletionProvider.ts +++ b/src/autocomplete/ResourceStateCompletionProvider.ts @@ -53,12 +53,15 @@ export class ResourceStateCompletionProvider implements CompletionProvider { } log.info(`Retrieving resource details from AWS account with id: ${identifier} and type: ${resource.Type}`); - const result = await this.resourceStateManager.getResource(resource.Type, identifier); - const properties = result.resource?.properties; - if (!properties) { + let properties: string; + try { + const resourceState = await this.resourceStateManager.getResource(resource.Type, identifier); + properties = resourceState.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/resourceState/ResourceStateImporter.ts b/src/resourceState/ResourceStateImporter.ts index d658d0a..9fec326 100644 --- a/src/resourceState/ResourceStateImporter.ts +++ b/src/resourceState/ResourceStateImporter.ts @@ -176,39 +176,25 @@ export class ResourceStateImporter { } for (const resourceIdentifier of resourceSelection.resourceIdentifiers) { try { - const result = await this.resourceStateManager.getResource(resourceType, resourceIdentifier); - if (result.resource) { - 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), - }, - }); - } else { - this.getOrCreate(importResult.failedImports, resourceType, []).push(resourceIdentifier); - if (result.error) { - importResult.failureReasons ??= {}; - importResult.failureReasons[resourceType] ??= {}; - importResult.failureReasons[resourceType][resourceIdentifier] = result.error; - } - } + const resourceState = await this.resourceStateManager.getResource(resourceType, resourceIdentifier); + 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), + }, + }); } catch (error) { log.error(error, `Error importing resource state for ${resourceType} id: ${resourceIdentifier}`); this.getOrCreate(importResult.failedImports, resourceType, []).push(resourceIdentifier); diff --git a/tst/unit/autocomplete/ResourceStateCompletionProvider.test.ts b/tst/unit/autocomplete/ResourceStateCompletionProvider.test.ts index d4e277d..84e52d1 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({}); + mockComponents.resourceStateManager.getResource.resolves(); const result = await provider.getCompletions(context, mockYamlParams); @@ -132,14 +132,7 @@ describe('ResourceStateCompletionProvider', () => { }); mockComponents.schemaRetriever.getDefault.returns(s3Schemas); - mockComponents.resourceStateManager.getResource.resolves({ - resource: { - 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); @@ -159,16 +152,14 @@ describe('ResourceStateCompletionProvider', () => { emptySchemas.schemas.set('Custom::Type', customTypeSchema); mockComponents.schemaRetriever.getDefault.returns(emptySchemas); mockComponents.resourceStateManager.getResource.resolves({ - resource: { - typeName: 'Custom::Type', - identifier: 'test', - properties: JSON.stringify({ - BucketName: 'test', - VersioningConfiguration: { Status: 'Enabled' }, - ExistingProp: 'value', - }), - createdTimestamp: new Date() as any, - }, + 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); @@ -196,15 +187,13 @@ describe('ResourceStateCompletionProvider', () => { emptySchemas.schemas.set('Custom::Type', customTypeSchema); mockComponents.schemaRetriever.getDefault.returns(emptySchemas); mockComponents.resourceStateManager.getResource.resolves({ - resource: { - typeName: 'Custom::Type', - identifier: 'test', - properties: JSON.stringify({ - BucketName: 'test', - VersioningConfiguration: { Status: 'Enabled' }, - }), - createdTimestamp: new Date() as any, - }, + 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); @@ -279,12 +268,10 @@ describe('ResourceStateCompletionProvider', () => { }); mockComponents.resourceStateManager.getResource.resolves({ - 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(), - }, + 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); @@ -320,12 +307,10 @@ describe('ResourceStateCompletionProvider', () => { }); mockComponents.resourceStateManager.getResource.resolves({ - 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(), - }, + 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); @@ -474,15 +459,13 @@ describe('ResourceStateCompletionProvider', () => { emptySchemas.schemas.set('Custom::Type', customTypeSchema); mockComponents.schemaRetriever.getDefault.returns(emptySchemas); mockComponents.resourceStateManager.getResource.resolves({ - resource: { - typeName: 'Custom::Type', - identifier: 'test', - properties: JSON.stringify({ - BucketName: 'test', - VersioningConfiguration: { Status: 'Enabled' }, - }), - createdTimestamp: new Date() as any, - }, + 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); @@ -507,15 +490,13 @@ describe('ResourceStateCompletionProvider', () => { mockComponents.schemaRetriever.getDefault.returns(emptySchemas); mockComponents.documentManager.getLine.returns('"",'); mockComponents.resourceStateManager.getResource.resolves({ - resource: { - typeName: 'Custom::Type', - identifier: 'test', - properties: JSON.stringify({ - BucketName: 'test', - VersioningConfiguration: { Status: 'Enabled' }, - }), - createdTimestamp: new Date() as any, - }, + 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 9c65075..a8e1070 100644 --- a/tst/unit/resourceState/ResourceStateImporter.test.ts +++ b/tst/unit/resourceState/ResourceStateImporter.test.ts @@ -92,7 +92,7 @@ describe('ResourceStateImporter', () => { }, ]; - mockResourceStateManager.getResource.mockResolvedValue({ resource: mockResource }); + mockResourceStateManager.getResource.mockResolvedValue(mockResource); const params: ResourceStateParams = { resourceSelections, @@ -136,7 +136,7 @@ describe('ResourceStateImporter', () => { }, ]; - mockResourceStateManager.getResource.mockResolvedValue({ resource: mockResource }); + mockResourceStateManager.getResource.mockResolvedValue(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 }, @@ -230,7 +233,7 @@ describe('ResourceStateImporter', () => { }); const mockResource = createMockResourceState('AWS::S3::Bucket'); - mockResourceStateManager.getResource.mockResolvedValue({ resource: mockResource }); + mockResourceStateManager.getResource.mockResolvedValue(mockResource); const params: ResourceStateParams = { resourceSelections: [ @@ -264,7 +267,7 @@ describe('ResourceStateImporter', () => { }); const mockResource = createMockResourceState('AWS::S3::Bucket'); - mockResourceStateManager.getResource.mockResolvedValue({ resource: mockResource }); + mockResourceStateManager.getResource.mockResolvedValue(mockResource); const params: ResourceStateParams = { resourceSelections: [ @@ -294,7 +297,7 @@ describe('ResourceStateImporter', () => { }); const mockResource = createMockResourceState('AWS::S3::Bucket'); - mockResourceStateManager.getResource.mockResolvedValue({ resource: mockResource }); + mockResourceStateManager.getResource.mockResolvedValue(mockResource); const params: ResourceStateParams = { resourceSelections: [ @@ -344,7 +347,7 @@ describe('ResourceStateImporter', () => { }, ]; - mockResourceStateManager.getResource.mockResolvedValue({ resource: mockResource }); + mockResourceStateManager.getResource.mockResolvedValue(mockResource); const params: ResourceStateParams = { resourceSelections, @@ -384,9 +387,9 @@ describe('ResourceStateImporter', () => { ]; mockResourceStateManager.getResource - .mockResolvedValueOnce({ resource: mockResource1 }) - .mockResolvedValueOnce({ resource: mockResource2 }) - .mockResolvedValueOnce({ resource: mockResource3 }); + .mockResolvedValueOnce(mockResource1) + .mockResolvedValueOnce(mockResource2) + .mockResolvedValueOnce(mockResource3); const params: ResourceStateParams = { resourceSelections, diff --git a/tst/unit/resourceState/ResourceStateManager.test.ts b/tst/unit/resourceState/ResourceStateManager.test.ts index 6aa630c..2bbce45 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.resource?.properties).toEqual('{"BucketName": "my-bucket"}'); + expect(result?.properties).toEqual('{"BucketName": "my-bucket"}'); await manager.getResource('AWS::S3::Bucket', 'my-bucket'); expect(mockCcapiService.getResource).toHaveBeenCalledOnce(); }); @@ -66,56 +66,41 @@ describe('ResourceStateManager', () => { const result = await manager.getResource('AWS::S3::Bucket', 'my-bucket'); expect(result).toEqual({ - resource: { - typeName: 'AWS::S3::Bucket', - identifier: 'my-bucket', - properties: '{"BucketName": "my-bucket"}', - createdTimestamp: expect.any(DateTime), - }, + typeName: 'AWS::S3::Bucket', + identifier: 'my-bucket', + properties: '{"BucketName": "my-bucket"}', + createdTimestamp: expect.any(DateTime), }); }); - it('should handle ResourceNotFoundException', async () => { + it('should throw ResourceNotFoundError for ResourceNotFoundException', async () => { const error = new ResourceNotFoundException({ message: 'Resource not found', $metadata: { httpStatusCode: 404 }, }); vi.mocked(mockCcapiService.getResource).mockRejectedValue(error); - const result = await manager.getResource('AWS::S3::Bucket', 'nonexistent'); - - expect(result.error).toBe('Resource not found'); + await expect(manager.getResource('AWS::S3::Bucket', 'nonexistent')).rejects.toThrow('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' }; - vi.mocked(mockCcapiService.getResource).mockRejectedValue(error); - - const result = await manager.getResource('AWS::S3::Bucket', 'my-bucket'); - - expect(result.error).toBe('Service error'); - }); - - it('should handle AccessDeniedException with user-friendly message', async () => { + it('should rethrow 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.error).toBe(error.message); + await expect(manager.getResource('AWS::S3::Bucket', 'my-bucket')).rejects.toThrow(error.message); }); - it('should handle missing required fields in output', async () => { + it('should throw InvalidResourceOutputError for missing required fields', async () => { const mockOutput: GetResourceCommandOutput = { TypeName: 'AWS::S3::Bucket', ResourceDescription: { @@ -126,9 +111,9 @@ describe('ResourceStateManager', () => { }; vi.mocked(mockCcapiService.getResource).mockResolvedValue(mockOutput); - const result = await manager.getResource('AWS::S3::Bucket', 'my-bucket'); - - expect(result.error).toBe('GetResource output is missing required fields'); + await expect(manager.getResource('AWS::S3::Bucket', 'my-bucket')).rejects.toThrow( + 'GetResource output is missing required fields', + ); }); }); From 1668d1ab2a42d47a1eaf5c437da1653bb7e6edef Mon Sep 17 00:00:00 2001 From: gemammercado Date: Thu, 2 Apr 2026 11:22:52 -0400 Subject: [PATCH 03/10] fix more merge conflicts --- src/resourceState/ResourceStateManager.ts | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/src/resourceState/ResourceStateManager.ts b/src/resourceState/ResourceStateManager.ts index 3f85d25..d19239d 100644 --- a/src/resourceState/ResourceStateManager.ts +++ b/src/resourceState/ResourceStateManager.ts @@ -17,7 +17,6 @@ import { isClientError } from '../utils/AwsErrorMapper'; import { Closeable } from '../utils/Closeable'; import { NO_LIST_SUPPORT, REQUIRES_RESOURCE_MODEL } from './ListResourcesExclusionTypes'; import { ListResourcesResult, RefreshResourcesResult } from './ResourceStateTypes'; -import { cached } from 'zod/v4/core/util.cjs'; const log = LoggerFactory.getLogger('ResourceStateManager'); @@ -86,7 +85,7 @@ export class ResourceStateManager implements SettingsConfigurable, Closeable { } if (!output?.TypeName || !output?.ResourceDescription?.Identifier || !output?.ResourceDescription?.Properties) { - throw new Error ( + throw new Error( `GetResource output is missing required fields for type ${typeName} with identifier "${identifier}"`, ); } @@ -141,7 +140,7 @@ export class ResourceStateManager implements SettingsConfigurable, Closeable { } catch { return { found: false }; } - + // Add to cache const cached = this.resourceListMap.get(typeName); if (cached && !cached.resourceIdentifiers.includes(identifier)) { From 872621652d316966284d767d1843ed966128c5a5 Mon Sep 17 00:00:00 2001 From: gemammercado Date: Thu, 23 Apr 2026 14:06:42 -0400 Subject: [PATCH 04/10] implement pr feedback --- src/resourceState/ResourceStateImporter.ts | 4 +- .../ResourceStateCompletionProvider.test.ts | 2 +- .../ResourceStateImporter.test.ts | 73 +++++++++++++++++++ 3 files changed, 76 insertions(+), 3 deletions(-) diff --git a/src/resourceState/ResourceStateImporter.ts b/src/resourceState/ResourceStateImporter.ts index 9fec326..7fa3314 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 { @@ -160,7 +161,6 @@ export class ResourceStateImporter { completionItem: undefined, failedImports: {}, successfulImports: {}, - failureReasons: {}, }; const generatedLogicalIds = new Set(); @@ -198,7 +198,7 @@ export class ResourceStateImporter { } catch (error) { log.error(error, `Error importing resource state for ${resourceType} id: ${resourceIdentifier}`); this.getOrCreate(importResult.failedImports, resourceType, []).push(resourceIdentifier); - const errorMessage = error instanceof Error ? error.message : String(error); + const errorMessage = extractErrorMessage(error); importResult.failureReasons ??= {}; importResult.failureReasons[resourceType] ??= {}; importResult.failureReasons[resourceType][resourceIdentifier] = errorMessage; diff --git a/tst/unit/autocomplete/ResourceStateCompletionProvider.test.ts b/tst/unit/autocomplete/ResourceStateCompletionProvider.test.ts index 84e52d1..f7778b9 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(); + mockComponents.resourceStateManager.getResource.rejects(new Error('Resource not found')); const result = await provider.getCompletions(context, mockYamlParams); diff --git a/tst/unit/resourceState/ResourceStateImporter.test.ts b/tst/unit/resourceState/ResourceStateImporter.test.ts index a8e1070..0182e69 100644 --- a/tst/unit/resourceState/ResourceStateImporter.test.ts +++ b/tst/unit/resourceState/ResourceStateImporter.test.ts @@ -219,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.mockRejectedValue(new Error('Access denied')); + + const params: ResourceStateParams = { + resourceSelections: [{ resourceType: 'AWS::S3::Bucket', resourceIdentifiers: ['my-bucket'] }], + textDocument: { uri } as any, + 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(mockResource); + + const params: ResourceStateParams = { + resourceSelections: [ + { resourceType: 'AWS::S3::Bucket', resourceIdentifiers: [mockResource.identifier] }, + ], + textDocument: { uri } as any, + 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(mockResource) + .mockRejectedValueOnce(new Error('Not found')) + .mockRejectedValueOnce(new Error('Timeout')); + + const params: ResourceStateParams = { + resourceSelections: [ + { + resourceType: 'AWS::S3::Bucket', + resourceIdentifiers: [mockResource.identifier, 'bad-bucket', 'timeout-bucket'], + }, + ], + textDocument: { uri } as any, + 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]; From fc9ed58e864f5a6d575244404943f9223eb31dbf Mon Sep 17 00:00:00 2001 From: gemammercado Date: Thu, 23 Apr 2026 14:27:11 -0400 Subject: [PATCH 05/10] fix merge conflict --- src/resourceState/ResourceStateManager.ts | 3 --- 1 file changed, 3 deletions(-) diff --git a/src/resourceState/ResourceStateManager.ts b/src/resourceState/ResourceStateManager.ts index d19239d..83b496d 100644 --- a/src/resourceState/ResourceStateManager.ts +++ b/src/resourceState/ResourceStateManager.ts @@ -13,7 +13,6 @@ import { DefaultSettings, ProfileSettings } from '../settings/Settings'; import { LoggerFactory } from '../telemetry/LoggerFactory'; import { ScopedTelemetry } from '../telemetry/ScopedTelemetry'; import { Telemetry, Measure, Count } from '../telemetry/TelemetryDecorator'; -import { isClientError } from '../utils/AwsErrorMapper'; import { Closeable } from '../utils/Closeable'; import { NO_LIST_SUPPORT, REQUIRES_RESOURCE_MODEL } from './ListResourcesExclusionTypes'; import { ListResourcesResult, RefreshResourcesResult } from './ResourceStateTypes'; @@ -76,8 +75,6 @@ export class ResourceStateManager implements SettingsConfigurable, Closeable { } catch (error) { if (error instanceof ResourceNotFoundException) { log.info(`No resource found for type ${typeName} and identifier "${identifier}"`); - } else if (isClientError(error)) { - log.info(`Client error for type ${typeName} and identifier "${identifier}"`); } else { log.error(error, `CCAPI GetResource failed for type ${typeName} and identifier "${identifier}"`); } From 7e502221dedfbbca61416b61a7af0e245efc5f16 Mon Sep 17 00:00:00 2001 From: gemammercado Date: Mon, 27 Apr 2026 17:03:57 -0400 Subject: [PATCH 06/10] update failureMessage type --- src/autocomplete/ResourceStateCompletionProvider.ts | 3 +++ src/resourceState/ResourceStateImporter.ts | 4 ++++ src/resourceState/ResourceStateManager.ts | 7 ++++--- tst/unit/resourceState/ResourceStateManager.test.ts | 6 ++---- 4 files changed, 13 insertions(+), 7 deletions(-) diff --git a/src/autocomplete/ResourceStateCompletionProvider.ts b/src/autocomplete/ResourceStateCompletionProvider.ts index f238395..be14fd0 100644 --- a/src/autocomplete/ResourceStateCompletionProvider.ts +++ b/src/autocomplete/ResourceStateCompletionProvider.ts @@ -56,6 +56,9 @@ export class ResourceStateCompletionProvider implements CompletionProvider { let properties: string; try { const resourceState = await this.resourceStateManager.getResource(resource.Type, identifier); + if (!resourceState) { + return []; + } properties = resourceState.properties; } catch { log.info(`No resource found for id: ${identifier} and type: ${resource.Type}`); diff --git a/src/resourceState/ResourceStateImporter.ts b/src/resourceState/ResourceStateImporter.ts index 7fa3314..0b902ba 100644 --- a/src/resourceState/ResourceStateImporter.ts +++ b/src/resourceState/ResourceStateImporter.ts @@ -177,6 +177,10 @@ export class ResourceStateImporter { for (const resourceIdentifier of resourceSelection.resourceIdentifiers) { try { const resourceState = await this.resourceStateManager.getResource(resourceType, resourceIdentifier); + if (!resourceState) { + this.getOrCreate(importResult.failedImports, resourceType, []).push(resourceIdentifier); + continue; + } this.getOrCreate(importResult.successfulImports, resourceType, []).push(resourceIdentifier); const logicalId = this.generateUniqueLogicalId( resourceType, diff --git a/src/resourceState/ResourceStateManager.ts b/src/resourceState/ResourceStateManager.ts index 83b496d..27878c1 100644 --- a/src/resourceState/ResourceStateManager.ts +++ b/src/resourceState/ResourceStateManager.ts @@ -58,8 +58,8 @@ export class ResourceStateManager implements SettingsConfigurable, Closeable { this.initializeCounters(); } - @Measure({ name: 'getResource' }) - public async getResource(typeName: ResourceType, identifier: ResourceId): Promise { + @Measure({ name: 'getResource', captureErrorType: true }) + public async getResource(typeName: ResourceType, identifier: ResourceId): Promise { const cachedResources = this.getResourceState(typeName, identifier); if (cachedResources) { this.telemetry.count('state.hit', 1); @@ -82,9 +82,10 @@ export class ResourceStateManager implements SettingsConfigurable, Closeable { } if (!output?.TypeName || !output?.ResourceDescription?.Identifier || !output?.ResourceDescription?.Properties) { - throw new Error( + log.error( `GetResource output is missing required fields for type ${typeName} with identifier "${identifier}"`, ); + return; } const value: ResourceState = { diff --git a/tst/unit/resourceState/ResourceStateManager.test.ts b/tst/unit/resourceState/ResourceStateManager.test.ts index 2bbce45..6c5b378 100644 --- a/tst/unit/resourceState/ResourceStateManager.test.ts +++ b/tst/unit/resourceState/ResourceStateManager.test.ts @@ -100,7 +100,7 @@ describe('ResourceStateManager', () => { await expect(manager.getResource('AWS::S3::Bucket', 'my-bucket')).rejects.toThrow(error.message); }); - it('should throw InvalidResourceOutputError for missing required fields', async () => { + it('should return undefined for missing required fields', async () => { const mockOutput: GetResourceCommandOutput = { TypeName: 'AWS::S3::Bucket', ResourceDescription: { @@ -111,9 +111,7 @@ describe('ResourceStateManager', () => { }; vi.mocked(mockCcapiService.getResource).mockResolvedValue(mockOutput); - await expect(manager.getResource('AWS::S3::Bucket', 'my-bucket')).rejects.toThrow( - 'GetResource output is missing required fields', - ); + await expect(manager.getResource('AWS::S3::Bucket', 'my-bucket')).resolves.toBeUndefined(); }); }); From 2d676b6d327cbc4a8b2426fd06da2ed96679abc0 Mon Sep 17 00:00:00 2001 From: gemammercado Date: Mon, 27 Apr 2026 17:13:43 -0400 Subject: [PATCH 07/10] lint fixes --- src/schema/ResourceSchema.ts | 2 +- tst/unit/resourceState/ResourceStateImporter.test.ts | 6 +++--- 2 files changed, 4 insertions(+), 4 deletions(-) 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/resourceState/ResourceStateImporter.test.ts b/tst/unit/resourceState/ResourceStateImporter.test.ts index 0182e69..5a97ed1 100644 --- a/tst/unit/resourceState/ResourceStateImporter.test.ts +++ b/tst/unit/resourceState/ResourceStateImporter.test.ts @@ -229,7 +229,7 @@ describe('ResourceStateImporter', () => { const params: ResourceStateParams = { resourceSelections: [{ resourceType: 'AWS::S3::Bucket', resourceIdentifiers: ['my-bucket'] }], - textDocument: { uri } as any, + textDocument: { uri }, purpose: ResourceStatePurpose.IMPORT, }; @@ -252,7 +252,7 @@ describe('ResourceStateImporter', () => { resourceSelections: [ { resourceType: 'AWS::S3::Bucket', resourceIdentifiers: [mockResource.identifier] }, ], - textDocument: { uri } as any, + textDocument: { uri }, purpose: ResourceStatePurpose.IMPORT, }; @@ -280,7 +280,7 @@ describe('ResourceStateImporter', () => { resourceIdentifiers: [mockResource.identifier, 'bad-bucket', 'timeout-bucket'], }, ], - textDocument: { uri } as any, + textDocument: { uri }, purpose: ResourceStatePurpose.IMPORT, }; From 28068f880e3fe3b04138cbf6f3b05baee96a43b3 Mon Sep 17 00:00:00 2001 From: gemammercado Date: Tue, 28 Apr 2026 13:31:46 -0400 Subject: [PATCH 08/10] always throw --- src/resourceState/ResourceStateManager.ts | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/resourceState/ResourceStateManager.ts b/src/resourceState/ResourceStateManager.ts index 27878c1..61e3666 100644 --- a/src/resourceState/ResourceStateManager.ts +++ b/src/resourceState/ResourceStateManager.ts @@ -13,6 +13,7 @@ import { DefaultSettings, ProfileSettings } from '../settings/Settings'; import { LoggerFactory } from '../telemetry/LoggerFactory'; import { ScopedTelemetry } from '../telemetry/ScopedTelemetry'; import { Telemetry, Measure, Count } from '../telemetry/TelemetryDecorator'; +import { isClientError } from '../utils/AwsErrorMapper'; import { Closeable } from '../utils/Closeable'; import { NO_LIST_SUPPORT, REQUIRES_RESOURCE_MODEL } from './ListResourcesExclusionTypes'; import { ListResourcesResult, RefreshResourcesResult } from './ResourceStateTypes'; @@ -75,6 +76,8 @@ export class ResourceStateManager implements SettingsConfigurable, Closeable { } catch (error) { if (error instanceof ResourceNotFoundException) { log.info(`No resource found for type ${typeName} and identifier "${identifier}"`); + } else if (isClientError(error)) { + log.info(`Client error for type ${typeName} and identifier "${identifier}"`); } else { log.error(error, `CCAPI GetResource failed for type ${typeName} and identifier "${identifier}"`); } From e61a85469ec42d7f80788164008262eb061bca94 Mon Sep 17 00:00:00 2001 From: gemammercado Date: Tue, 28 Apr 2026 14:45:07 -0400 Subject: [PATCH 09/10] return import failure message as object --- .../ResourceStateCompletionProvider.ts | 6 +- src/resourceState/ResourceStateImporter.ts | 24 +++-- src/resourceState/ResourceStateManager.ts | 23 +++-- .../ResourceStateCompletionProvider.test.ts | 88 +++++++++++-------- .../ResourceStateImporter.test.ts | 28 +++--- .../ResourceStateManager.test.ts | 30 ++++--- 6 files changed, 120 insertions(+), 79 deletions(-) diff --git a/src/autocomplete/ResourceStateCompletionProvider.ts b/src/autocomplete/ResourceStateCompletionProvider.ts index be14fd0..c2cf23e 100644 --- a/src/autocomplete/ResourceStateCompletionProvider.ts +++ b/src/autocomplete/ResourceStateCompletionProvider.ts @@ -55,11 +55,11 @@ export class ResourceStateCompletionProvider implements CompletionProvider { log.info(`Retrieving resource details from AWS account with id: ${identifier} and type: ${resource.Type}`); let properties: string; try { - const resourceState = await this.resourceStateManager.getResource(resource.Type, identifier); - if (!resourceState) { + const result = await this.resourceStateManager.getResource(resource.Type, identifier); + if (!result.resource) { return []; } - properties = resourceState.properties; + properties = result.resource.properties; } catch { log.info(`No resource found for id: ${identifier} and type: ${resource.Type}`); return []; diff --git a/src/resourceState/ResourceStateImporter.ts b/src/resourceState/ResourceStateImporter.ts index 0b902ba..a977500 100644 --- a/src/resourceState/ResourceStateImporter.ts +++ b/src/resourceState/ResourceStateImporter.ts @@ -157,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: {}, @@ -176,9 +177,13 @@ export class ResourceStateImporter { } for (const resourceIdentifier of resourceSelection.resourceIdentifiers) { try { - const resourceState = await this.resourceStateManager.getResource(resourceType, resourceIdentifier); - if (!resourceState) { + 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); @@ -195,7 +200,12 @@ export class ResourceStateImporter { Type: resourceType, DeletionPolicy: purpose === ResourceStatePurpose.IMPORT ? DeletionPolicyOnImport : undefined, - Properties: this.applyTransformations(resourceState.properties, schema, purpose, logicalId), + Properties: this.applyTransformations( + result.resource.properties, + schema, + purpose, + logicalId, + ), Metadata: await this.createMetadata(resourceIdentifier, purpose), }, }); @@ -203,12 +213,14 @@ export class ResourceStateImporter { log.error(error, `Error importing resource state for ${resourceType} id: ${resourceIdentifier}`); this.getOrCreate(importResult.failedImports, resourceType, []).push(resourceIdentifier); const errorMessage = extractErrorMessage(error); - importResult.failureReasons ??= {}; - importResult.failureReasons[resourceType] ??= {}; - importResult.failureReasons[resourceType][resourceIdentifier] = errorMessage; + 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 61e3666..cb86191 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,11 +63,11 @@ 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); @@ -76,11 +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 {}; } else if (isClientError(error)) { log.info(`Client error for type ${typeName} and identifier "${identifier}"`); - } else { - log.error(error, `CCAPI GetResource failed for type ${typeName} and identifier "${identifier}"`); + 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' }) @@ -136,12 +140,17 @@ export class ResourceStateManager implements SettingsConfigurable, Closeable { typeName: string, identifier: string, ): Promise<{ found: boolean; resourceList?: ResourceList }> { + let result: GetResourceResult; try { - await this.getResource(typeName, identifier); + result = await this.getResource(typeName, identifier); } catch { return { found: false }; } + if (!result.resource) { + return { found: false }; + } + // Add to cache const cached = this.resourceListMap.get(typeName); if (cached && !cached.resourceIdentifiers.includes(identifier)) { diff --git a/tst/unit/autocomplete/ResourceStateCompletionProvider.test.ts b/tst/unit/autocomplete/ResourceStateCompletionProvider.test.ts index f7778b9..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.rejects(new Error('Resource not found')); + mockComponents.resourceStateManager.getResource.resolves({ error: 'Resource not found' }); const result = await provider.getCompletions(context, mockYamlParams); @@ -152,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); @@ -187,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); @@ -268,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); @@ -307,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); @@ -459,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); @@ -490,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 5a97ed1..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, @@ -225,7 +225,7 @@ describe('ResourceStateImporter', () => { createAndRegisterDocument(uri, scenario.initialContent, scenario.documentType); - mockResourceStateManager.getResource.mockRejectedValue(new Error('Access denied')); + mockResourceStateManager.getResource.mockResolvedValue({ error: 'Access denied' }); const params: ResourceStateParams = { resourceSelections: [{ resourceType: 'AWS::S3::Bucket', resourceIdentifiers: ['my-bucket'] }], @@ -246,7 +246,7 @@ describe('ResourceStateImporter', () => { createAndRegisterDocument(uri, scenario.initialContent, scenario.documentType); const mockResource = createMockResourceState('AWS::S3::Bucket'); - mockResourceStateManager.getResource.mockResolvedValue(mockResource); + mockResourceStateManager.getResource.mockResolvedValue({ resource: mockResource }); const params: ResourceStateParams = { resourceSelections: [ @@ -269,9 +269,9 @@ describe('ResourceStateImporter', () => { const mockResource = createMockResourceState('AWS::S3::Bucket'); mockResourceStateManager.getResource - .mockResolvedValueOnce(mockResource) - .mockRejectedValueOnce(new Error('Not found')) - .mockRejectedValueOnce(new Error('Timeout')); + .mockResolvedValueOnce({ resource: mockResource }) + .mockResolvedValueOnce({ error: 'Not found' }) + .mockResolvedValueOnce({ error: 'Timeout' }); const params: ResourceStateParams = { resourceSelections: [ @@ -306,7 +306,7 @@ describe('ResourceStateImporter', () => { }); const mockResource = createMockResourceState('AWS::S3::Bucket'); - mockResourceStateManager.getResource.mockResolvedValue(mockResource); + mockResourceStateManager.getResource.mockResolvedValue({ resource: mockResource }); const params: ResourceStateParams = { resourceSelections: [ @@ -340,7 +340,7 @@ describe('ResourceStateImporter', () => { }); const mockResource = createMockResourceState('AWS::S3::Bucket'); - mockResourceStateManager.getResource.mockResolvedValue(mockResource); + mockResourceStateManager.getResource.mockResolvedValue({ resource: mockResource }); const params: ResourceStateParams = { resourceSelections: [ @@ -370,7 +370,7 @@ describe('ResourceStateImporter', () => { }); const mockResource = createMockResourceState('AWS::S3::Bucket'); - mockResourceStateManager.getResource.mockResolvedValue(mockResource); + mockResourceStateManager.getResource.mockResolvedValue({ resource: mockResource }); const params: ResourceStateParams = { resourceSelections: [ @@ -420,7 +420,7 @@ describe('ResourceStateImporter', () => { }, ]; - mockResourceStateManager.getResource.mockResolvedValue(mockResource); + mockResourceStateManager.getResource.mockResolvedValue({ resource: mockResource }); const params: ResourceStateParams = { resourceSelections, @@ -460,9 +460,9 @@ describe('ResourceStateImporter', () => { ]; mockResourceStateManager.getResource - .mockResolvedValueOnce(mockResource1) - .mockResolvedValueOnce(mockResource2) - .mockResolvedValueOnce(mockResource3); + .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 6c5b378..50cc0be 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,21 +66,25 @@ 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 throw ResourceNotFoundError for ResourceNotFoundException', async () => { + it('should return error for ResourceNotFoundException', async () => { const error = new ResourceNotFoundException({ message: 'Resource not found', $metadata: { httpStatusCode: 404 }, }); vi.mocked(mockCcapiService.getResource).mockRejectedValue(error); - await expect(manager.getResource('AWS::S3::Bucket', 'nonexistent')).rejects.toThrow('Resource not found'); + const result = await manager.getResource('AWS::S3::Bucket', 'nonexistent'); + expect(result.resource).toBeUndefined(); + expect(result.error).toBeUndefined(); }); it('should rethrow other errors', async () => { @@ -90,17 +94,19 @@ describe('ResourceStateManager', () => { await expect(manager.getResource('AWS::S3::Bucket', 'my-bucket')).rejects.toThrow('Service error'); }); - it('should rethrow AccessDeniedException', async () => { + 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); - await expect(manager.getResource('AWS::S3::Bucket', 'my-bucket')).rejects.toThrow(error.message); + const result = await manager.getResource('AWS::S3::Bucket', 'my-bucket'); + expect(result.resource).toBeUndefined(); + expect(result.error).toContain('not authorized to perform'); }); - it('should return undefined for missing required fields', async () => { + it('should return error for missing required fields', async () => { const mockOutput: GetResourceCommandOutput = { TypeName: 'AWS::S3::Bucket', ResourceDescription: { @@ -111,7 +117,9 @@ describe('ResourceStateManager', () => { }; vi.mocked(mockCcapiService.getResource).mockResolvedValue(mockOutput); - await expect(manager.getResource('AWS::S3::Bucket', 'my-bucket')).resolves.toBeUndefined(); + const result = await manager.getResource('AWS::S3::Bucket', 'my-bucket'); + expect(result.resource).toBeUndefined(); + expect(result.error).toBe('GetResource output is missing required fields'); }); }); From 3edccca90b24f1d7e25ced5de979b0d16d072215 Mon Sep 17 00:00:00 2001 From: gemammercado Date: Wed, 29 Apr 2026 14:50:01 -0400 Subject: [PATCH 10/10] pass no found exception to client --- src/handlers/ResourceHandler.ts | 1 + src/resourceState/ResourceStateManager.ts | 6 +++--- src/resourceState/ResourceStateTypes.ts | 1 + tst/unit/resourceState/ResourceStateManager.test.ts | 2 +- 4 files changed, 6 insertions(+), 4 deletions(-) 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/ResourceStateManager.ts b/src/resourceState/ResourceStateManager.ts index cb86191..a2557ef 100644 --- a/src/resourceState/ResourceStateManager.ts +++ b/src/resourceState/ResourceStateManager.ts @@ -79,7 +79,7 @@ export class ResourceStateManager implements SettingsConfigurable, Closeable { } catch (error) { if (error instanceof ResourceNotFoundException) { log.info(`No resource found for type ${typeName} and identifier "${identifier}"`); - return {}; + return { error: extractErrorMessage(error) }; } else if (isClientError(error)) { log.info(`Client error for type ${typeName} and identifier "${identifier}"`); return { error: extractErrorMessage(error) }; @@ -139,7 +139,7 @@ export class ResourceStateManager implements SettingsConfigurable, Closeable { public async searchResourceByIdentifier( typeName: string, identifier: string, - ): Promise<{ found: boolean; resourceList?: ResourceList }> { + ): Promise<{ found: boolean; resourceList?: ResourceList; error?: string }> { let result: GetResourceResult; try { result = await this.getResource(typeName, identifier); @@ -148,7 +148,7 @@ export class ResourceStateManager implements SettingsConfigurable, Closeable { } if (!result.resource) { - return { found: false }; + return { found: false, error: result.error }; } // Add to cache diff --git a/src/resourceState/ResourceStateTypes.ts b/src/resourceState/ResourceStateTypes.ts index a8f0510..923b0e6 100644 --- a/src/resourceState/ResourceStateTypes.ts +++ b/src/resourceState/ResourceStateTypes.ts @@ -89,6 +89,7 @@ export type SearchResourceParams = { export type SearchResourceResult = { found: boolean; resource?: ResourceSummary; + error?: string; }; export const SearchResourceRequest = new RequestType( diff --git a/tst/unit/resourceState/ResourceStateManager.test.ts b/tst/unit/resourceState/ResourceStateManager.test.ts index 50cc0be..6017bca 100644 --- a/tst/unit/resourceState/ResourceStateManager.test.ts +++ b/tst/unit/resourceState/ResourceStateManager.test.ts @@ -84,7 +84,7 @@ describe('ResourceStateManager', () => { const result = await manager.getResource('AWS::S3::Bucket', 'nonexistent'); expect(result.resource).toBeUndefined(); - expect(result.error).toBeUndefined(); + expect(result.error).toContain('Resource not found'); }); it('should rethrow other errors', async () => {