@@ -248,10 +248,51 @@ describe('S3Generator', () => {
248248 const content = writtenFile ( 'resource.ts' ) ;
249249 const normalized = content . replace ( / \s + / g, ' ' ) ;
250250
251- expect ( normalized ) . toMatch (
252- / ' p r i v a t e \/ \{ e n t i t y _ i d \} \/ \* ' : \s * \[ \s * a l l o w \. e n t i t y \( ' i d e n t i t y ' \) \. t o \( \[ ' r e a d ' , ' w r i t e ' , ' d e l e t e ' \] \) \s * , ? \s * \] / ,
253- ) ;
254- expect ( normalized ) . toMatch ( / ' p r o t e c t e d \/ \{ e n t i t y _ i d \} \/ \* ' : \s * \[ \s * a l l o w \. e n t i t y \( ' i d e n t i t y ' \) \. t o \( \[ ' r e a d ' , ' w r i t e ' , ' d e l e t e ' \] \) / ) ;
251+ // Positive assertion: full generated file locks the per-user mapping (owner scoped to
252+ // entity('identity'); protected/ retains authenticated read).
253+ expect ( content ) . toMatchInlineSnapshot ( `
254+ "import { defineStorage } from '@aws-amplify/backend';
255+ import { CfnResource } from 'aws-cdk-lib';
256+ import type { Backend } from '../backend';
257+
258+ const branchName = process.env.AWS_BRANCH ?? 'sandbox';
259+
260+ export const storage = defineStorage({
261+ name: \`myBucket-main-\${branchName}\`,
262+ access: (allow) => ({
263+ 'public/*': [allow.authenticated.to(['read', 'write', 'delete'])],
264+ 'protected/{entity_id}/*': [
265+ allow.entity('identity').to(['read', 'write', 'delete']),
266+ allow.authenticated.to(['read']),
267+ ],
268+ 'private/{entity_id}/*': [
269+ allow.entity('identity').to(['read', 'write', 'delete']),
270+ ],
271+ }),
272+ });
273+
274+ export function postRefactor(backend: Backend) {
275+ const s3Bucket = backend.storage.resources.cfnResources.cfnBucket;
276+ s3Bucket.bucketName = 'myBucket-main-abc123';
277+ }
278+
279+ export function applyEscapeHatches(backend: Backend) {
280+ const s3Bucket = backend.storage.resources.cfnResources.cfnBucket;
281+ for (const cfnResource of backend.storage.stack.node
282+ .findAll()
283+ .filter(
284+ (c) =>
285+ CfnResource.isCfnResource(c) &&
286+ ['AWS::S3::Bucket', 'Custom::S3AutoDeleteObjects'].includes(
287+ c.cfnResourceType
288+ )
289+ )) {
290+ (cfnResource as CfnResource).addOverride('UpdateReplacePolicy', 'Retain');
291+ (cfnResource as CfnResource).addOverride('DeletionPolicy', 'Retain');
292+ }
293+ }
294+ "
295+ ` ) ;
255296
256297 // private/ and protected/ must not grant write/delete via allow.authenticated (public/*
257298 // legitimately can, so the check is scoped to those paths). Rules render in the order
@@ -266,6 +307,75 @@ describe('S3Generator', () => {
266307 }
267308 } ) ;
268309
310+ it ( 'scopes write-only authenticated protected access and retains authenticated read' , async ( ) => {
311+ const gen1App = await createGen1App ( {
312+ providers : { awscloudformation : { StackName : 'amplify-test-main-123456' , Region : 'us-east-1' } } ,
313+ storage : { myBucket : { service : 'S3' , output : { BucketName : 'myBucket-main-abc123' } } } ,
314+ } ) ;
315+ // Write-only Gen1 auth (no READ in the auth set): the owner rule must still render as
316+ // entity('identity').to(['write']) and protected/ must still get the unconditional
317+ // authenticated read (Gen1 cross-user read semantics).
318+ jest . spyOn ( gen1App , 'cliInputs' ) . mockReturnValue ( {
319+ authAccess : [ 'CREATE_AND_UPDATE' ] ,
320+ guestAccess : [ ] ,
321+ } ) ;
322+ jest . spyOn ( gen1App . aws , 'fetchBucketAccelerate' ) . mockResolvedValue ( undefined ) ;
323+ jest . spyOn ( gen1App . aws , 'fetchBucketVersioning' ) . mockResolvedValue ( undefined ) ;
324+ jest . spyOn ( gen1App . aws , 'fetchBucketEncryption' ) . mockResolvedValue ( undefined ) ;
325+
326+ const generator = new S3Generator (
327+ gen1App ,
328+ backendGenerator ,
329+ outputDir ,
330+ { category : 'storage' , resourceName : 'myBucket' , service : 'S3' , key : 'storage:S3' } ,
331+ logger ,
332+ ) ;
333+ const ops = await generator . plan ( ) ;
334+ await ops [ 0 ] . execute ( ) ;
335+
336+ expect ( writtenFile ( 'resource.ts' ) ) . toMatchInlineSnapshot ( `
337+ "import { defineStorage } from '@aws-amplify/backend';
338+ import { CfnResource } from 'aws-cdk-lib';
339+ import type { Backend } from '../backend';
340+
341+ const branchName = process.env.AWS_BRANCH ?? 'sandbox';
342+
343+ export const storage = defineStorage({
344+ name: \`myBucket-main-\${branchName}\`,
345+ access: (allow) => ({
346+ 'public/*': [allow.authenticated.to(['write'])],
347+ 'protected/{entity_id}/*': [
348+ allow.entity('identity').to(['write']),
349+ allow.authenticated.to(['read']),
350+ ],
351+ 'private/{entity_id}/*': [allow.entity('identity').to(['write'])],
352+ }),
353+ });
354+
355+ export function postRefactor(backend: Backend) {
356+ const s3Bucket = backend.storage.resources.cfnResources.cfnBucket;
357+ s3Bucket.bucketName = 'myBucket-main-abc123';
358+ }
359+
360+ export function applyEscapeHatches(backend: Backend) {
361+ const s3Bucket = backend.storage.resources.cfnResources.cfnBucket;
362+ for (const cfnResource of backend.storage.stack.node
363+ .findAll()
364+ .filter(
365+ (c) =>
366+ CfnResource.isCfnResource(c) &&
367+ ['AWS::S3::Bucket', 'Custom::S3AutoDeleteObjects'].includes(
368+ c.cfnResourceType
369+ )
370+ )) {
371+ (cfnResource as CfnResource).addOverride('UpdateReplacePolicy', 'Retain');
372+ (cfnResource as CfnResource).addOverride('DeletionPolicy', 'Retain');
373+ }
374+ }
375+ "
376+ ` ) ;
377+ } ) ;
378+
269379 it ( 'renders guest access patterns' , async ( ) => {
270380 const gen1App = await createGen1App ( {
271381 providers : { awscloudformation : { StackName : 'amplify-test-main-123456' , Region : 'us-east-1' } } ,
0 commit comments