diff --git a/examples/basic-usage.ts b/examples/basic-usage.ts index 3448cea..79bc3c3 100644 --- a/examples/basic-usage.ts +++ b/examples/basic-usage.ts @@ -1,5 +1,4 @@ #!/usr/bin/env node -import 'source-map-support/register'; import { App, Stack } from 'aws-cdk-lib'; import { SecureBucket, SecurityLevel } from '../src'; diff --git a/examples/blueprints-usage.ts b/examples/blueprints-usage.ts index f756a0a..885a16e 100644 --- a/examples/blueprints-usage.ts +++ b/examples/blueprints-usage.ts @@ -1,5 +1,4 @@ #!/usr/bin/env node -import 'source-map-support/register'; import { App, Stack, PropertyInjectors } from 'aws-cdk-lib'; import { Bucket } from 'aws-cdk-lib/aws-s3'; diff --git a/jest.config.js b/jest.config.js index 59418af..d2ed01e 100644 --- a/jest.config.js +++ b/jest.config.js @@ -12,7 +12,6 @@ module.exports = { collectCoverageFrom: [ 'src/**/*.ts', '!src/**/*.d.ts', - '!src/**/index.ts', ], coverageDirectory: 'coverage', coverageReporters: ['text', 'lcov', 'html'], diff --git a/src/resources/s3-bucket/secure-bucket.ts b/src/resources/s3-bucket/secure-bucket.ts index a024314..7449e12 100644 --- a/src/resources/s3-bucket/secure-bucket.ts +++ b/src/resources/s3-bucket/secure-bucket.ts @@ -70,6 +70,26 @@ export class SecureBucket extends Construct { /** The underlying hardened bucket. */ public readonly bucket: Bucket; + /** The bucket's ARN. Convenience pass-through for `this.bucket.bucketArn`. */ + public get bucketArn(): string { + return this.bucket.bucketArn; + } + + /** The bucket's name. Convenience pass-through for `this.bucket.bucketName`. */ + public get bucketName(): string { + return this.bucket.bucketName; + } + + /** The bucket's domain name. Convenience pass-through for `this.bucket.bucketDomainName`. */ + public get bucketDomainName(): string { + return this.bucket.bucketDomainName; + } + + /** The bucket's regional domain name. Convenience pass-through for `this.bucket.bucketRegionalDomainName`. */ + public get bucketRegionalDomainName(): string { + return this.bucket.bucketRegionalDomainName; + } + constructor(scope: Construct, id: string, props: SecureBucketProps = {}) { super(scope, id); diff --git a/test/core/compliance-registry.test.ts b/test/core/compliance-registry.test.ts new file mode 100644 index 0000000..3e7c6de --- /dev/null +++ b/test/core/compliance-registry.test.ts @@ -0,0 +1,33 @@ +import { ComplianceRegistry } from '../../src'; +import { S3BucketCompliance } from '../../src/resources/s3-bucket/compliance'; + +describe('ComplianceRegistry', () => { + it('returns a report for every exposed resource', () => { + const reports = ComplianceRegistry.all(); + + expect(Array.isArray(reports)).toBe(true); + expect(reports.length).toBeGreaterThanOrEqual(1); + }); + + it('stays in sync with the S3 compliance report', () => { + const reports = ComplianceRegistry.all(); + const s3Report = S3BucketCompliance.report(); + + expect(reports).toContainEqual(s3Report); + }); + + it('every report entry has a resource, version, and non-empty controls', () => { + for (const report of ComplianceRegistry.all()) { + expect(report.resourceType).toBeTruthy(); + expect(report.version).toBeTruthy(); + expect(Array.isArray(report.controls)).toBe(true); + expect(report.controls.length).toBeGreaterThan(0); + for (const control of report.controls) { + expect(control.id).toBeTruthy(); + expect(control.title).toBeTruthy(); + expect(control.status).toBeTruthy(); + expect(control.enforcedBy).toBeTruthy(); + } + } + }); +}); diff --git a/test/resources/s3-bucket/fields.test.ts b/test/resources/s3-bucket/fields.test.ts new file mode 100644 index 0000000..c100171 --- /dev/null +++ b/test/resources/s3-bucket/fields.test.ts @@ -0,0 +1,84 @@ +import { RemovalPolicy } from 'aws-cdk-lib'; + +import { + secureMaxVersioned, + secureMaxRemovalPolicy, +} from '../../../src/resources/s3-bucket/fields'; + +describe('fields: secureMaxVersioned', () => { + it('should enable versioning when the tier value is true', () => { + expect(secureMaxVersioned(true, undefined)).toBe(true); + expect(secureMaxVersioned(true, false)).toBe(true); + expect(secureMaxVersioned(true, true)).toBe(true); + }); + + it('should never weaken an enabled tier value to false', () => { + expect(secureMaxVersioned(true, false)).toBe(true); + }); + + it('should adopt an incoming true value when the tier value is false', () => { + expect(secureMaxVersioned(false, true)).toBe(true); + }); + + it('should stay disabled when both values are false/undefined', () => { + expect(secureMaxVersioned(false, undefined)).toBe(false); + expect(secureMaxVersioned(false, false)).toBe(false); + }); + + it('should treat truthy/incoming coercion correctly', () => { + expect(secureMaxVersioned(false, true)).toBe(true); + expect(secureMaxVersioned(true, false)).toBe(true); + }); +}); + +describe('fields: secureMaxRemovalPolicy', () => { + it('should return the tier value when incoming is undefined', () => { + expect(secureMaxRemovalPolicy(RemovalPolicy.RETAIN, undefined)).toBe(RemovalPolicy.RETAIN); + expect(secureMaxRemovalPolicy(RemovalPolicy.DESTROY, undefined)).toBe(RemovalPolicy.DESTROY); + }); + + it('should never weaken a RETAIN tier', () => { + expect(secureMaxRemovalPolicy(RemovalPolicy.RETAIN, RemovalPolicy.DESTROY)).toBe( + RemovalPolicy.RETAIN + ); + expect(secureMaxRemovalPolicy(RemovalPolicy.RETAIN, RemovalPolicy.SNAPSHOT)).toBe( + RemovalPolicy.RETAIN + ); + expect(secureMaxRemovalPolicy(RemovalPolicy.RETAIN, RemovalPolicy.RETAIN)).toBe( + RemovalPolicy.RETAIN + ); + }); + + it('should adopt an incoming policy that is at least as strong as the tier', () => { + expect(secureMaxRemovalPolicy(RemovalPolicy.DESTROY, RemovalPolicy.SNAPSHOT)).toBe( + RemovalPolicy.SNAPSHOT + ); + expect(secureMaxRemovalPolicy(RemovalPolicy.DESTROY, RemovalPolicy.RETAIN)).toBe( + RemovalPolicy.RETAIN + ); + expect(secureMaxRemovalPolicy(RemovalPolicy.SNAPSHOT, RemovalPolicy.RETAIN)).toBe( + RemovalPolicy.RETAIN + ); + }); + + it('should keep the weaker tier value when incoming is weaker', () => { + expect(secureMaxRemovalPolicy(RemovalPolicy.SNAPSHOT, RemovalPolicy.DESTROY)).toBe( + RemovalPolicy.SNAPSHOT + ); + expect(secureMaxRemovalPolicy(RemovalPolicy.DESTROY, RemovalPolicy.DESTROY)).toBe( + RemovalPolicy.DESTROY + ); + }); + + it('should rank SNAPSHOT between DESTROY and RETAIN', () => { + expect(secureMaxRemovalPolicy(RemovalPolicy.SNAPSHOT, RemovalPolicy.SNAPSHOT)).toBe( + RemovalPolicy.SNAPSHOT + ); + expect(secureMaxRemovalPolicy(RemovalPolicy.DESTROY, RemovalPolicy.SNAPSHOT)).toBe( + RemovalPolicy.SNAPSHOT + ); + expect(secureMaxRemovalPolicy(RemovalPolicy.SNAPSHOT, RemovalPolicy.RETAIN)).toBe( + RemovalPolicy.RETAIN + ); + }); +}); diff --git a/test/resources/s3-bucket/secure-bucket.test.ts b/test/resources/s3-bucket/secure-bucket.test.ts index d6a02fd..717df35 100644 --- a/test/resources/s3-bucket/secure-bucket.test.ts +++ b/test/resources/s3-bucket/secure-bucket.test.ts @@ -184,4 +184,27 @@ describe('SecureBucket', () => { expect(secureBucket.bucket.node.id).toBe('Bucket'); }); }); + + describe('attribute pass-through getters', () => { + it('exposes bucketArn and bucketName directly on the construct', () => { + const bucket = new SecureBucket(stack, 'TestBucket', { + bucketName: 'my-secure-bucket', + }); + + expect(bucket.bucketArn).toBeDefined(); + expect(bucket.bucketArn).toBe(bucket.bucket.bucketArn); + expect(bucket.bucketName).toBe(bucket.bucket.bucketName); + expect(bucket.bucketDomainName).toBe(bucket.bucket.bucketDomainName); + expect(bucket.bucketRegionalDomainName).toBe(bucket.bucket.bucketRegionalDomainName); + }); + + it('resolves getters to the same values as the wrapped bucket', () => { + const bucket = new SecureBucket(stack, 'TestBucket', { + bucketName: 'direct-access', + }); + + expect(bucket.bucketArn).toBe(bucket.bucket.bucketArn); + expect(bucket.bucketName).toBe(bucket.bucket.bucketName); + }); + }); });