@@ -1507,4 +1507,111 @@ describe('Hosting', () => {
15071507 ) ;
15081508 } ) ;
15091509 } ) ;
1510+
1511+ describe ( 'config.json stale-placeholder guard (#173)' , ( ) => {
1512+ // Helper: pull every BucketDeployment custom resource's Properties.
1513+ const bucketDeployments = ( stack : Stack ) => {
1514+ const crs = Template . fromStack ( stack ) . findResources (
1515+ 'Custom::CDKBucketDeployment' ,
1516+ ) ;
1517+ return Object . values ( crs ) . map (
1518+ ( cr ) => ( cr as { Properties : Record < string , unknown > } ) . Properties ,
1519+ ) ;
1520+ } ;
1521+
1522+ it ( 'uploads the placeholder config.json with a no-cache directive, never the 1-year mutable cache-control' , ( ) => {
1523+ // The build-time placeholder (`{_placeholder:true}`) is written into the
1524+ // static dir. If it inherits the mutable asset tier's
1525+ // `s-maxage=31536000` and an edge caches it during the deploy window, the
1526+ // edge serves the placeholder for up to a year — breaking every client
1527+ // API call. It must instead be uploaded as a no-cache path so the edge
1528+ // never caches it long-term.
1529+ createSpaBuildOutput ( tmpDir ) ;
1530+ const app = new App ( ) ;
1531+ const stack = new Stack ( app , 'PlaceholderCacheStack' ) ;
1532+ new Hosting ( stack , 'Hosting' , { root : tmpDir , api : MOCK_API } ) ;
1533+
1534+ const deployments = bucketDeployments ( stack ) ;
1535+ const cc = ( p : Record < string , unknown > ) =>
1536+ ( p . SystemMetadata as Record < string , string > | undefined ) ?. [ 'cache-control' ] ;
1537+ const includes = ( p : Record < string , unknown > ) =>
1538+ ( p . Include as string [ ] | undefined ) ?? [ ] ;
1539+ const excludes = ( p : Record < string , unknown > ) =>
1540+ ( p . Exclude as string [ ] | undefined ) ?? [ ] ;
1541+
1542+ // A deployment must upload `.blocks-sandbox/config.json` with the
1543+ // no-cache directive (this is the placeholder upload).
1544+ const noCacheDeploy = deployments . find (
1545+ ( p ) =>
1546+ includes ( p ) . includes ( '.blocks-sandbox/config.json' ) &&
1547+ cc ( p ) === 'no-cache, no-store, must-revalidate' ,
1548+ ) ;
1549+ assert . ok (
1550+ noCacheDeploy ,
1551+ 'placeholder .blocks-sandbox/config.json must be uploaded with ' +
1552+ '"no-cache, no-store, must-revalidate"' ,
1553+ ) ;
1554+
1555+ // No deployment may cover the placeholder with the 1-year mutable
1556+ // cache-control: the mutable/other tier must EXCLUDE it.
1557+ const leaks = deployments . filter ( ( p ) => {
1558+ const directive = cc ( p ) ;
1559+ return (
1560+ typeof directive === 'string' &&
1561+ directive . includes ( 's-maxage=31536000' ) &&
1562+ ! excludes ( p ) . includes ( '.blocks-sandbox/config.json' )
1563+ ) ;
1564+ } ) ;
1565+ assert . strictEqual (
1566+ leaks . length ,
1567+ 0 ,
1568+ 'no mutable-tier deployment may apply s-maxage=31536000 to ' +
1569+ '.blocks-sandbox/config.json' ,
1570+ ) ;
1571+ } ) ;
1572+
1573+ it ( 'registers the placeholder as a no-cache path even for a static-only site (no api)' , ( ) => {
1574+ // The placeholder is always written (step 5), so its no-cache
1575+ // registration must not depend on `props.api`. This guards against a
1576+ // regression that moves the registration inside an `if (props.api)`
1577+ // block, which would reopen the stale-placeholder window for
1578+ // static-only sites.
1579+ createSpaBuildOutput ( tmpDir ) ;
1580+ const app = new App ( ) ;
1581+ const stack = new Stack ( app , 'StaticOnlyPlaceholderStack' ) ;
1582+ new Hosting ( stack , 'Hosting' , { root : tmpDir } ) ;
1583+
1584+ Template . fromStack ( stack ) . hasResourceProperties (
1585+ 'Custom::CDKBucketDeployment' ,
1586+ Match . objectLike ( {
1587+ Include : Match . arrayWith ( [ '.blocks-sandbox/config.json' ] ) ,
1588+ SystemMetadata : Match . objectLike ( {
1589+ 'cache-control' : 'no-cache, no-store, must-revalidate' ,
1590+ } ) ,
1591+ } ) ,
1592+ ) ;
1593+ } ) ;
1594+
1595+ it ( 'invalidates the post-rewrite cache key (/builds/<id>/.blocks-sandbox/*)' , ( ) => {
1596+ // The viewer-request skew-protection function rewrites the URI to
1597+ // `/builds/<buildId>/.blocks-sandbox/config.json` BEFORE the cache
1598+ // lookup, so the real edge cache key lives under `/builds/<id>/`.
1599+ // Invalidating only `/.blocks-sandbox/*` never matches it.
1600+ createSpaBuildOutput ( tmpDir ) ;
1601+ const app = new App ( ) ;
1602+ const stack = new Stack ( app , 'InvalidationPathStack' ) ;
1603+ new Hosting ( stack , 'Hosting' , { root : tmpDir , api : MOCK_API } ) ;
1604+
1605+ // Assert via Template + Match so a CDK property rename fails loudly here
1606+ // rather than silently skipping a structural-heuristic lookup.
1607+ Template . fromStack ( stack ) . hasResourceProperties (
1608+ 'Custom::CDKBucketDeployment' ,
1609+ Match . objectLike ( {
1610+ DistributionPaths : Match . arrayWith ( [
1611+ Match . stringLikeRegexp ( '^/builds/.+/\\.blocks-sandbox/\\*$' ) ,
1612+ ] ) ,
1613+ } ) ,
1614+ ) ;
1615+ } ) ;
1616+ } ) ;
15101617} ) ;
0 commit comments