From 2a28d9bd5aa884c328f7badeee87dedda391120d Mon Sep 17 00:00:00 2001 From: Simone Zhang Date: Thu, 6 Aug 2026 12:49:55 +0200 Subject: [PATCH 1/5] feat(core): add shared execution role + executionRole getter Provision one explicit IAM role on the Blocks stack/backend (with AWSLambdaBasicExecutionRole attached for CloudWatch Logs) and have the handler assume it instead of an auto-generated role. Expose it as executionRole and add a Scope.executionRole getter that resolves the role from any Building Block via the same tree-walk as get handler(). Block grants sit on the role's default (inline) policy, exactly as they did on the auto-role, so behavior is unchanged. Additive and non-breaking: no block references executionRole yet. Implements Multi-Compute A1 (#1017). --- .changeset/core-shared-execution-role.md | 12 +++ packages/core/src/cdk/blocks-backend.test.ts | 91 +++++++++++++++++++- packages/core/src/cdk/blocks-backend.ts | 22 ++++- packages/core/src/cdk/index.ts | 23 +++++ 4 files changed, 146 insertions(+), 2 deletions(-) create mode 100644 .changeset/core-shared-execution-role.md diff --git a/.changeset/core-shared-execution-role.md b/.changeset/core-shared-execution-role.md new file mode 100644 index 00000000..bdb630b8 --- /dev/null +++ b/.changeset/core-shared-execution-role.md @@ -0,0 +1,12 @@ +--- +"@aws-blocks/core": patch +--- + +feat(core): add a shared Blocks execution role and `Scope.executionRole` getter + +The Blocks stack/backend now provisions one explicit IAM role (with +`AWSLambdaBasicExecutionRole` attached) that the handler assumes, and exposes it +as `executionRole`. A new `Scope.executionRole` getter resolves the role from +any Building Block. Additive and non-breaking: the same handler is created, now +backed by an explicit role instead of an auto-generated one, with block grants +sitting on the role's default policy exactly as before. diff --git a/packages/core/src/cdk/blocks-backend.test.ts b/packages/core/src/cdk/blocks-backend.test.ts index afc7b772..a0f6580f 100644 --- a/packages/core/src/cdk/blocks-backend.test.ts +++ b/packages/core/src/cdk/blocks-backend.test.ts @@ -6,8 +6,10 @@ import assert from 'node:assert'; import { fileURLToPath } from 'node:url'; import { dirname, join } from 'node:path'; import * as cdk from 'aws-cdk-lib'; -import { Template } from 'aws-cdk-lib/assertions'; +import { Template, Match } from 'aws-cdk-lib/assertions'; +import { PolicyStatement } from 'aws-cdk-lib/aws-iam'; import { BlocksBackend } from './blocks-backend.js'; +import { Scope } from './index.js'; // Simulate the CDK condition being active (tests import CDK files directly) before(() => { @@ -19,6 +21,7 @@ const handlerPath = join(__dirname, '__fixtures__', 'handler.js'); const sideEffectBackendPath = join(__dirname, '__fixtures__', 'side-effect-backend.js'); const factoryBackendPath = join(__dirname, '__fixtures__', 'factory-backend.js'); const fullIdConstructBackendPath = join(__dirname, '__fixtures__', 'fullid-construct-backend.js'); +const EXECUTION_ROLE_MARKER_ACTION = 'blocks-test:MarkerAction'; describe('ESM cache-busting (multi-stage)', () => { test('BlocksBackend.create() with same backendCDKPath but different IDs produces constructs in each', async () => { @@ -89,6 +92,92 @@ describe('synth shape (drop into existing stack)', () => { }); }); +describe('shared execution role (A1)', () => { + test('exposes executionRole on the backend', async () => { + const app = new cdk.App(); + const parent = new cdk.Stack(app, 'RoleSurfaceStack'); + + const backend = await BlocksBackend.create(parent, 'Blocks', { + backendHandlerPath: handlerPath, + backendCDKPath: sideEffectBackendPath, + }); + + assert.ok(backend.executionRole, 'BlocksBackend should expose .executionRole'); + }); + + test('synth produces a Lambda-assumable role with basic execution, and the handler uses it', async () => { + const app = new cdk.App(); + const parent = new cdk.Stack(app, 'RoleSynthStack'); + + await BlocksBackend.create(parent, 'Blocks', { + backendHandlerPath: handlerPath, + backendCDKPath: sideEffectBackendPath, + }); + + const template = Template.fromStack(parent); + + // The shared role (logical id derived from the 'BlocksRole' construct id) + // is assumable by Lambda and carries AWSLambdaBasicExecutionRole (so + // CloudWatch Logs keep working after swapping off the auto-role). Other + // roles exist (API Gateway CloudWatch role, config BucketDeployment role), + // so we target ours by logical id. + const roles = template.findResources('AWS::IAM::Role'); + const blocksRoleId = Object.keys(roles).find(k => k.includes('BlocksRole')); + assert.ok(blocksRoleId, 'expected a role from the BlocksRole construct'); + const blocksRole = roles[blocksRoleId]; + assert.deepStrictEqual(blocksRole.Properties.AssumeRolePolicyDocument.Statement[0], { + Action: 'sts:AssumeRole', + Effect: 'Allow', + Principal: { Service: 'lambda.amazonaws.com' }, + }); + assert.ok( + JSON.stringify(blocksRole.Properties.ManagedPolicyArns ?? []).includes('AWSLambdaBasicExecutionRole'), + 'BlocksRole should attach AWSLambdaBasicExecutionRole', + ); + + // The Blocks handler references the shared role, not an auto-generated one. + template.hasResourceProperties('AWS::Lambda::Function', { + Role: { 'Fn::GetAtt': [blocksRoleId, 'Arn'] }, + }); + }); + + test('a nested block resolves executionRole via the construct-tree walk', async () => { + const app = new cdk.App(); + const parent = new cdk.Stack(app, 'RoleResolveStack'); + + const backend = await BlocksBackend.create(parent, 'Blocks', { + backendHandlerPath: handlerPath, + backendCDKPath: sideEffectBackendPath, + }); + + // Build nested Scopes under the backend (outer → inner), the same shape a + // real Building Block tree has, and grant a uniquely-named marker action to + // `this.executionRole` from the innermost scope. If the getter's tree-walk + // failed, it would resolve the wrong role (or throw), and the marker would + // not land on the backend's shared role. + // `create()` sets globalThis.CURRENT_BLOCKS_STACK = backend, so a parent-less + // Scope attaches under the backend (the same way a real backend module's + // top-level blocks do); `inner` is then nested one level deeper. + const outer = new Scope('outer'); + const inner = new Scope('inner', { parent: outer }); + + // Resolves to the backend's shared role from two levels deep. + assert.strictEqual(inner.executionRole, backend.executionRole); + + inner.executionRole.addToPrincipalPolicy( + new PolicyStatement({ actions: [EXECUTION_ROLE_MARKER_ACTION], resources: ['*'] }), + ); + + // The grant lands on the shared role's default inline policy (AWS::IAM::Policy). + const template = Template.fromStack(parent); + template.hasResourceProperties('AWS::IAM::Policy', { + PolicyDocument: { + Statement: Match.arrayWith([Match.objectLike({ Action: EXECUTION_ROLE_MARKER_ACTION })]), + }, + }); + }); +}); + describe('factory function support', () => { test('BlocksBackend.create() calls default export function with the backend instance', async () => { const app = new cdk.App(); diff --git a/packages/core/src/cdk/blocks-backend.ts b/packages/core/src/cdk/blocks-backend.ts index 7387d26f..5fb8d889 100644 --- a/packages/core/src/cdk/blocks-backend.ts +++ b/packages/core/src/cdk/blocks-backend.ts @@ -4,6 +4,7 @@ import * as cdk from 'aws-cdk-lib'; import * as lambda from 'aws-cdk-lib/aws-lambda-nodejs'; import * as apigateway from 'aws-cdk-lib/aws-apigateway'; +import * as iam from 'aws-cdk-lib/aws-iam'; import { CfnGroup } from 'aws-cdk-lib/aws-resourcegroups'; import { Construct } from 'constructs'; import { pathToFileURL } from 'node:url'; @@ -48,10 +49,26 @@ export interface BlocksBackendProps { /** Shared infra setup — creates Lambda + API Gateway on the given scope. */ export function setupBlocksInfra(scope: Construct, props: BlocksBackendProps, id?: string) { + // ── Shared execution role ────────────────────────────────────────────── + // A single IAM role that every Building Block grants to. Provisioned here so + // it exists before the backend module is imported (Building Blocks reach it + // via `scope.executionRole`). Block grants sit on the role's default (inline) + // policy, exactly as they did on the auto-generated NodejsFunction role. + // + // AWSLambdaBasicExecutionRole is attached explicitly because the auto-role + // included it by default — omitting it would silently break CloudWatch Logs. + const executionRole = new iam.Role(scope, 'BlocksRole', { + assumedBy: new iam.CompositePrincipal(new iam.ServicePrincipal('lambda.amazonaws.com')), + managedPolicies: [ + iam.ManagedPolicy.fromAwsManagedPolicyName('service-role/AWSLambdaBasicExecutionRole'), + ], + }); + const handler = new lambda.NodejsFunction(scope, 'Handler', { entry: props.backendHandlerPath, runtime: DEFAULT_NODE_RUNTIME, handler: 'handler', + role: executionRole, memorySize: 2048, timeout: cdk.Duration.seconds(60 * 15), environment: { @@ -152,7 +169,7 @@ export function setupBlocksInfra(scope: Construct, props: BlocksBackendProps, id registerBuiltinRoutes(); - return { handler, gateway: api, apiUrl: `${api.url}${BLOCKS_RPC_PREFIX.slice(1)}` }; + return { handler, gateway: api, apiUrl: `${api.url}${BLOCKS_RPC_PREFIX.slice(1)}`, executionRole }; } /** @@ -177,6 +194,8 @@ export class BlocksBackend extends Construct { public readonly gateway: apigateway.RestApi; public readonly handler: cdk.aws_lambda_nodejs.NodejsFunction; public readonly backendHandlerPath: string; + /** Shared IAM role assumed by all Blocks compute. Building Blocks grant to this role. */ + public readonly executionRole: iam.IRole; /** * The fullId used by child Scopes to compute their env var names, @@ -221,6 +240,7 @@ export class BlocksBackend extends Construct { this.handler = infra.handler; this.gateway = infra.gateway; this.apiUrl = infra.apiUrl; + this.executionRole = infra.executionRole; // Override BLOCKS_STACK_NAME to include the parent stack name so runtime // resource lookups (DynamoDB table names) match the CDK-time fullId diff --git a/packages/core/src/cdk/index.ts b/packages/core/src/cdk/index.ts index 2e938eba..5b771069 100644 --- a/packages/core/src/cdk/index.ts +++ b/packages/core/src/cdk/index.ts @@ -30,6 +30,8 @@ export class BlocksStack extends cdk.Stack implements BaseBlocksStack { public readonly gateway: cdk.aws_apigateway.RestApi; public readonly handler: cdk.aws_lambda_nodejs.NodejsFunction; public readonly backendHandlerPath: string; + /** Shared IAM role assumed by all Blocks compute. Building Blocks grant to this role. */ + public readonly executionRole: cdk.aws_iam.IRole; private constructor(scope: Construct, id: string, props: BlocksStackProps) { super(scope, id, props); @@ -43,6 +45,7 @@ export class BlocksStack extends cdk.Stack implements BaseBlocksStack { this.handler = infra.handler; this.gateway = infra.gateway; this.apiUrl = infra.apiUrl; + this.executionRole = infra.executionRole; } static async create(scope: Construct, id: string, props: BlocksStackProps) { @@ -103,6 +106,26 @@ export class Scope extends Construct { return ((globalThis as any).CURRENT_BLOCKS_STACK as { handler: cdk.aws_lambda_nodejs.NodejsFunction }).handler; } + /** + * The shared IAM role assumed by all Blocks compute. Building Blocks grant + * their permissions to this role (grants accumulate in the stack's shared + * managed policy) instead of to an individual function's auto-role. + * + * Resolves the same way as {@link handler}: walk up to the owning + * BlocksStack/BlocksBackend, falling back to the ambient stack. + */ + get executionRole(): cdk.aws_iam.IRole { + let current: Construct = this; + while (current.node.scope) { + current = current.node.scope as Construct; + if (current instanceof BlocksStack || current instanceof BlocksBackend) { + return current.executionRole; + } + } + // Fallback to globalThis for backward compatibility + return ((globalThis as any).CURRENT_BLOCKS_STACK as { executionRole: cdk.aws_iam.IRole }).executionRole; + } + get fullId(): string { return computeScopeFullId(this); } From f86b25c3cc599f2ae98dd6caacf9a7c21790c13b Mon Sep 17 00:00:00 2001 From: Simone Zhang Date: Thu, 6 Aug 2026 16:20:45 +0200 Subject: [PATCH 2/5] test(core): drop internal roadmap tag from describe block MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Rename the test suite from 'shared execution role (A1)' to 'shared execution role' — the internal issue tag doesn't belong in shipped code. --- packages/core/src/cdk/blocks-backend.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/core/src/cdk/blocks-backend.test.ts b/packages/core/src/cdk/blocks-backend.test.ts index a0f6580f..4d971bd3 100644 --- a/packages/core/src/cdk/blocks-backend.test.ts +++ b/packages/core/src/cdk/blocks-backend.test.ts @@ -92,7 +92,7 @@ describe('synth shape (drop into existing stack)', () => { }); }); -describe('shared execution role (A1)', () => { +describe('shared execution role', () => { test('exposes executionRole on the backend', async () => { const app = new cdk.App(); const parent = new cdk.Stack(app, 'RoleSurfaceStack'); From b60ad50f4a6848e7f6e7fb6ce0c50171b5c6c2da Mon Sep 17 00:00:00 2001 From: Simone Zhang Date: Thu, 6 Aug 2026 17:18:53 +0200 Subject: [PATCH 3/5] docs(core): address PR review on shared execution role - Fix executionRole getter docstring: grants land on the role's default (inline) policy, not a shared managed policy (matched impl + tests). - Document why the role uses CompositePrincipal (future compute types assume the same shared role). - Add a changeset migration note: upgrading replaces the Lambda execution role (delete+create), runtime-equivalent, no action needed. - Add BlocksStack coverage (executionRole surface + nested resolution) and a test for the getter's globalThis fallback branch. --- .changeset/core-shared-execution-role.md | 6 ++ packages/core/src/cdk/blocks-backend.ts | 3 + packages/core/src/cdk/blocks-stack.test.ts | 77 +++++++++++++++++++++- packages/core/src/cdk/index.ts | 6 +- 4 files changed, 89 insertions(+), 3 deletions(-) diff --git a/.changeset/core-shared-execution-role.md b/.changeset/core-shared-execution-role.md index bdb630b8..12773f7d 100644 --- a/.changeset/core-shared-execution-role.md +++ b/.changeset/core-shared-execution-role.md @@ -10,3 +10,9 @@ as `executionRole`. A new `Scope.executionRole` getter resolves the role from any Building Block. Additive and non-breaking: the same handler is created, now backed by an explicit role instead of an auto-generated one, with block grants sitting on the role's default policy exactly as before. + +Migration note: on an existing deployed stack, upgrading replaces the Lambda +execution role — CloudFormation deletes the old auto-generated role and creates +the new `BlocksRole`. This is runtime-equivalent (the same grants re-attach to +the new role) and needs no action, but a change-set diff will show a role +delete+create rather than a no-op. diff --git a/packages/core/src/cdk/blocks-backend.ts b/packages/core/src/cdk/blocks-backend.ts index 5fb8d889..315df242 100644 --- a/packages/core/src/cdk/blocks-backend.ts +++ b/packages/core/src/cdk/blocks-backend.ts @@ -58,6 +58,9 @@ export function setupBlocksInfra(scope: Construct, props: BlocksBackendProps, id // AWSLambdaBasicExecutionRole is attached explicitly because the auto-role // included it by default — omitting it would silently break CloudWatch Logs. const executionRole = new iam.Role(scope, 'BlocksRole', { + // CompositePrincipal (rather than a bare ServicePrincipal) so additional + // compute types can assume this same shared role as they are introduced + // (e.g. ECS tasks via ecs-tasks.amazonaws.com), by adding principals here. assumedBy: new iam.CompositePrincipal(new iam.ServicePrincipal('lambda.amazonaws.com')), managedPolicies: [ iam.ManagedPolicy.fromAwsManagedPolicyName('service-role/AWSLambdaBasicExecutionRole'), diff --git a/packages/core/src/cdk/blocks-stack.test.ts b/packages/core/src/cdk/blocks-stack.test.ts index ed0caf14..1c29d582 100644 --- a/packages/core/src/cdk/blocks-stack.test.ts +++ b/packages/core/src/cdk/blocks-stack.test.ts @@ -6,8 +6,10 @@ import assert from 'node:assert'; import { fileURLToPath } from 'node:url'; import { dirname, join } from 'node:path'; import * as cdk from 'aws-cdk-lib'; +import { Template, Match } from 'aws-cdk-lib/assertions'; +import { PolicyStatement } from 'aws-cdk-lib/aws-iam'; import { BlocksBackend } from './blocks-backend.js'; -import { BlocksStack } from './index.js'; +import { BlocksStack, Scope } from './index.js'; // Simulate the CDK condition being active (tests import CDK files directly) before(() => { @@ -78,6 +80,79 @@ describe('legacy side-effect mode (no default export)', () => { }); }); +describe('shared execution role (BlocksStack)', () => { + const MARKER_ACTION = 'blocks-test:StackMarkerAction'; + + test('BlocksStack exposes executionRole and the handler assumes it', async () => { + const app = new cdk.App(); + const stack = await BlocksStack.create(app, 'StackRoleStack', { + backendHandlerPath: handlerPath, + backendCDKPath: sideEffectBackendPath, + }); + + assert.ok(stack.executionRole, 'BlocksStack should expose .executionRole'); + + const template = Template.fromStack(stack); + const roles = template.findResources('AWS::IAM::Role'); + const blocksRoleId = Object.keys(roles).find(k => k.includes('BlocksRole')); + assert.ok(blocksRoleId, 'expected a role from the BlocksRole construct'); + assert.ok( + JSON.stringify(roles[blocksRoleId].Properties.ManagedPolicyArns ?? []).includes('AWSLambdaBasicExecutionRole'), + 'BlocksRole should attach AWSLambdaBasicExecutionRole', + ); + template.hasResourceProperties('AWS::Lambda::Function', { + Role: { 'Fn::GetAtt': [blocksRoleId, 'Arn'] }, + }); + }); + + test('a nested block resolves executionRole to the BlocksStack role', async () => { + const app = new cdk.App(); + const stack = await BlocksStack.create(app, 'StackRoleResolveStack', { + backendHandlerPath: handlerPath, + backendCDKPath: sideEffectBackendPath, + }); + + const outer = new Scope('outer'); + const inner = new Scope('inner', { parent: outer }); + assert.strictEqual(inner.executionRole, stack.executionRole, 'resolves up to the BlocksStack role'); + + inner.executionRole.addToPrincipalPolicy( + new PolicyStatement({ actions: [MARKER_ACTION], resources: ['*'] }), + ); + + const template = Template.fromStack(stack); + template.hasResourceProperties('AWS::IAM::Policy', { + PolicyDocument: { + Statement: Match.arrayWith([Match.objectLike({ Action: MARKER_ACTION })]), + }, + }); + }); +}); + +describe('executionRole globalThis fallback', () => { + test('resolves via globalThis.CURRENT_BLOCKS_STACK when no owner is in the tree', async () => { + const app = new cdk.App(); + const stack = await BlocksStack.create(app, 'FallbackStack', { + backendHandlerPath: handlerPath, + backendCDKPath: sideEffectBackendPath, + }); + + // A Scope whose construct-tree ancestry has no BlocksStack/BlocksBackend + // (parented under a plain cdk.Stack) exhausts the tree-walk and falls back + // to globalThis.CURRENT_BLOCKS_STACK. The `as any` is test plumbing — a + // plain Stack isn't a ScopeParent, but it IS a valid Construct parent. + const plainStack = new cdk.Stack(app, 'PlainStack'); + (globalThis as any).CURRENT_BLOCKS_STACK = stack; + const orphan = new Scope('orphan', { parent: plainStack as any }); + + assert.strictEqual( + orphan.executionRole, + stack.executionRole, + 'fallback resolves to the ambient stack role', + ); + }); +}); + describe('assertCdkConditionActive', () => { test('BlocksStack.create() throws when --conditions=cdk is missing', async () => { const origNodeOptions = process.env.NODE_OPTIONS; diff --git a/packages/core/src/cdk/index.ts b/packages/core/src/cdk/index.ts index 5b771069..3642a944 100644 --- a/packages/core/src/cdk/index.ts +++ b/packages/core/src/cdk/index.ts @@ -108,8 +108,10 @@ export class Scope extends Construct { /** * The shared IAM role assumed by all Blocks compute. Building Blocks grant - * their permissions to this role (grants accumulate in the stack's shared - * managed policy) instead of to an individual function's auto-role. + * their permissions to this role instead of to an individual function's + * auto-role. CDK's `grant*()` / `addToPrincipalPolicy()` route those grants + * to the role's default (inline) policy — exactly where they landed on the + * auto-generated role before. * * Resolves the same way as {@link handler}: walk up to the owning * BlocksStack/BlocksBackend, falling back to the ambient stack. From 4945953af52eee70687595e7d1f5f9e7e66520e5 Mon Sep 17 00:00:00 2001 From: Simone Zhang Date: Thu, 6 Aug 2026 17:25:31 +0200 Subject: [PATCH 4/5] test(core): drop cross-file duplication in BlocksStack role tests The role synth shape and the tree-walk grant landing on the role's inline policy are shared-code behaviors already covered in blocks-backend.test.ts. Trim blocks-stack.test.ts to only the BlocksStack-specific coverage (the stack wires + resolves the role); keep the globalThis fallback-branch test. --- packages/core/src/cdk/blocks-stack.test.ts | 47 +++++----------------- 1 file changed, 9 insertions(+), 38 deletions(-) diff --git a/packages/core/src/cdk/blocks-stack.test.ts b/packages/core/src/cdk/blocks-stack.test.ts index 1c29d582..ee030cf8 100644 --- a/packages/core/src/cdk/blocks-stack.test.ts +++ b/packages/core/src/cdk/blocks-stack.test.ts @@ -6,8 +6,6 @@ import assert from 'node:assert'; import { fileURLToPath } from 'node:url'; import { dirname, join } from 'node:path'; import * as cdk from 'aws-cdk-lib'; -import { Template, Match } from 'aws-cdk-lib/assertions'; -import { PolicyStatement } from 'aws-cdk-lib/aws-iam'; import { BlocksBackend } from './blocks-backend.js'; import { BlocksStack, Scope } from './index.js'; @@ -81,9 +79,12 @@ describe('legacy side-effect mode (no default export)', () => { }); describe('shared execution role (BlocksStack)', () => { - const MARKER_ACTION = 'blocks-test:StackMarkerAction'; - - test('BlocksStack exposes executionRole and the handler assumes it', async () => { + // The role synth shape (assume-role principal, AWSLambdaBasicExecutionRole, + // handler-assumes-role) and the tree-walk grant landing on the role's inline + // policy come from shared code (setupBlocksInfra + the Scope.executionRole + // getter) and are covered in blocks-backend.test.ts. Here we only cover what + // is BlocksStack-specific: that the stack wires + resolves the role. + test('BlocksStack exposes executionRole and a nested block resolves to it', async () => { const app = new cdk.App(); const stack = await BlocksStack.create(app, 'StackRoleStack', { backendHandlerPath: handlerPath, @@ -92,40 +93,10 @@ describe('shared execution role (BlocksStack)', () => { assert.ok(stack.executionRole, 'BlocksStack should expose .executionRole'); - const template = Template.fromStack(stack); - const roles = template.findResources('AWS::IAM::Role'); - const blocksRoleId = Object.keys(roles).find(k => k.includes('BlocksRole')); - assert.ok(blocksRoleId, 'expected a role from the BlocksRole construct'); - assert.ok( - JSON.stringify(roles[blocksRoleId].Properties.ManagedPolicyArns ?? []).includes('AWSLambdaBasicExecutionRole'), - 'BlocksRole should attach AWSLambdaBasicExecutionRole', - ); - template.hasResourceProperties('AWS::Lambda::Function', { - Role: { 'Fn::GetAtt': [blocksRoleId, 'Arn'] }, - }); - }); - - test('a nested block resolves executionRole to the BlocksStack role', async () => { - const app = new cdk.App(); - const stack = await BlocksStack.create(app, 'StackRoleResolveStack', { - backendHandlerPath: handlerPath, - backendCDKPath: sideEffectBackendPath, - }); - - const outer = new Scope('outer'); - const inner = new Scope('inner', { parent: outer }); + // Getter resolves through a BlocksStack owner (the backend test covers the + // BlocksBackend owner arm). + const inner = new Scope('inner', { parent: new Scope('outer') }); assert.strictEqual(inner.executionRole, stack.executionRole, 'resolves up to the BlocksStack role'); - - inner.executionRole.addToPrincipalPolicy( - new PolicyStatement({ actions: [MARKER_ACTION], resources: ['*'] }), - ); - - const template = Template.fromStack(stack); - template.hasResourceProperties('AWS::IAM::Policy', { - PolicyDocument: { - Statement: Match.arrayWith([Match.objectLike({ Action: MARKER_ACTION })]), - }, - }); }); }); From 87c39efcef9113af374ae6652cff697ea441dfa5 Mon Sep 17 00:00:00 2001 From: Simone Zhang Date: Thu, 6 Aug 2026 17:29:07 +0200 Subject: [PATCH 5/5] test(core): tighten BlocksStack role test to its unique behavior The nested-scope resolution assertion re-tested the shared Scope.executionRole tree-walk (covered in blocks-backend.test.ts). Reduce the BlocksStack test to the one thing that is genuinely stack-specific: its constructor wires a populated executionRole. --- packages/core/src/cdk/blocks-stack.test.ts | 18 ++++++------------ 1 file changed, 6 insertions(+), 12 deletions(-) diff --git a/packages/core/src/cdk/blocks-stack.test.ts b/packages/core/src/cdk/blocks-stack.test.ts index ee030cf8..2b0cae2e 100644 --- a/packages/core/src/cdk/blocks-stack.test.ts +++ b/packages/core/src/cdk/blocks-stack.test.ts @@ -79,24 +79,18 @@ describe('legacy side-effect mode (no default export)', () => { }); describe('shared execution role (BlocksStack)', () => { - // The role synth shape (assume-role principal, AWSLambdaBasicExecutionRole, - // handler-assumes-role) and the tree-walk grant landing on the role's inline - // policy come from shared code (setupBlocksInfra + the Scope.executionRole - // getter) and are covered in blocks-backend.test.ts. Here we only cover what - // is BlocksStack-specific: that the stack wires + resolves the role. - test('BlocksStack exposes executionRole and a nested block resolves to it', async () => { + // The role synth shape and the Scope.executionRole tree-walk are shared code + // (setupBlocksInfra + the getter), covered in blocks-backend.test.ts. The only + // BlocksStack-specific behavior is that its own constructor wires + // executionRole — a separate code path from BlocksBackend's constructor. + test('BlocksStack wires executionRole via its constructor', async () => { const app = new cdk.App(); const stack = await BlocksStack.create(app, 'StackRoleStack', { backendHandlerPath: handlerPath, backendCDKPath: sideEffectBackendPath, }); - assert.ok(stack.executionRole, 'BlocksStack should expose .executionRole'); - - // Getter resolves through a BlocksStack owner (the backend test covers the - // BlocksBackend owner arm). - const inner = new Scope('inner', { parent: new Scope('outer') }); - assert.strictEqual(inner.executionRole, stack.executionRole, 'resolves up to the BlocksStack role'); + assert.ok(stack.executionRole, 'BlocksStack should expose a populated .executionRole'); }); });