@forge/react

11.9.011.9.1-next.0
out/hooks/__test__/usePermissions.test.js
out/hooks/__test__/usePermissions.test.js
+363
Index: package/out/hooks/__test__/usePermissions.test.js
===================================================================
--- package/out/hooks/__test__/usePermissions.test.js
+++ package/out/hooks/__test__/usePermissions.test.js
@@ -362,8 +362,371 @@
                 }
             });
         });
     });
+    describe('CSP path matching for client fetch', () => {
+        it('should allow any path when allowlist has no path', async () => {
+            const mockContext = {
+                permissions: {
+                    external: {
+                        fetch: {
+                            client: ['https://cdn.example.com']
+                        }
+                    }
+                }
+            };
+            mockGetContext.mockResolvedValue(mockContext);
+            const requiredPermissions = {
+                external: {
+                    fetch: {
+                        client: [
+                            'https://cdn.example.com',
+                            'https://cdn.example.com/',
+                            'https://cdn.example.com/any/path',
+                            'https://cdn.example.com/file.js'
+                        ]
+                    }
+                }
+            };
+            const { result } = (0, react_hooks_1.renderHook)(() => (0, usePermissions_1.usePermissions)(requiredPermissions));
+            await (0, react_hooks_1.act)(async () => {
+                await new Promise((resolve) => setTimeout(resolve, 10));
+            });
+            expect(result.current.isLoading).toBe(false);
+            expect(result.current.hasPermission).toBe(true);
+        });
+        it('should use prefix matching when allowlist has trailing slash', async () => {
+            const mockContext = {
+                permissions: {
+                    external: {
+                        fetch: {
+                            client: ['https://cdn.example.com/api/']
+                        }
+                    }
+                }
+            };
+            mockGetContext.mockResolvedValue(mockContext);
+            const requiredPermissions = {
+                external: {
+                    fetch: {
+                        client: [
+                            'https://cdn.example.com/api/',
+                            'https://cdn.example.com/api/users',
+                            'https://cdn.example.com/api/v1/data'
+                        ]
+                    }
+                }
+            };
+            const { result } = (0, react_hooks_1.renderHook)(() => (0, usePermissions_1.usePermissions)(requiredPermissions));
+            await (0, react_hooks_1.act)(async () => {
+                await new Promise((resolve) => setTimeout(resolve, 10));
+            });
+            expect(result.current.isLoading).toBe(false);
+            expect(result.current.hasPermission).toBe(true);
+        });
+        it('should block paths outside prefix when using trailing slash', async () => {
+            const mockContext = {
+                permissions: {
+                    external: {
+                        fetch: {
+                            client: ['https://cdn.example.com/api/']
+                        }
+                    }
+                }
+            };
+            mockGetContext.mockResolvedValue(mockContext);
+            const requiredPermissions = {
+                external: {
+                    fetch: {
+                        client: ['https://cdn.example.com/other', 'https://cdn.example.com/']
+                    }
+                }
+            };
+            const { result } = (0, react_hooks_1.renderHook)(() => (0, usePermissions_1.usePermissions)(requiredPermissions));
+            await (0, react_hooks_1.act)(async () => {
+                await new Promise((resolve) => setTimeout(resolve, 10));
+            });
+            expect(result.current.isLoading).toBe(false);
+            expect(result.current.hasPermission).toBe(false);
+            expect(result.current.missingPermissions?.external?.fetch?.client).toEqual([
+                'https://cdn.example.com/other',
+                'https://cdn.example.com/'
+            ]);
+        });
+        it('should use exact matching when allowlist has no trailing slash', async () => {
+            const mockContext = {
+                permissions: {
+                    external: {
+                        fetch: {
+                            client: ['https://cdn.example.com/bundle.js']
+                        }
+                    }
+                }
+            };
+            mockGetContext.mockResolvedValue(mockContext);
+            const requiredPermissions = {
+                external: {
+                    fetch: {
+                        client: ['https://cdn.example.com/bundle.js']
+                    }
+                }
+            };
+            const { result } = (0, react_hooks_1.renderHook)(() => (0, usePermissions_1.usePermissions)(requiredPermissions));
+            await (0, react_hooks_1.act)(async () => {
+                await new Promise((resolve) => setTimeout(resolve, 10));
+            });
+            expect(result.current.isLoading).toBe(false);
+            expect(result.current.hasPermission).toBe(true);
+        });
+        it('should block non-exact paths when using exact matching', async () => {
+            const mockContext = {
+                permissions: {
+                    external: {
+                        fetch: {
+                            client: ['https://cdn.example.com/bundle.js']
+                        }
+                    }
+                }
+            };
+            mockGetContext.mockResolvedValue(mockContext);
+            const requiredPermissions = {
+                external: {
+                    fetch: {
+                        client: [
+                            'https://cdn.example.com/bundle.js/extra',
+                            'https://cdn.example.com/other.js',
+                            'https://cdn.example.com/'
+                        ]
+                    }
+                }
+            };
+            const { result } = (0, react_hooks_1.renderHook)(() => (0, usePermissions_1.usePermissions)(requiredPermissions));
+            await (0, react_hooks_1.act)(async () => {
+                await new Promise((resolve) => setTimeout(resolve, 10));
+            });
+            expect(result.current.isLoading).toBe(false);
+            expect(result.current.hasPermission).toBe(false);
+            expect(result.current.missingPermissions?.external?.fetch?.client).toEqual([
+                'https://cdn.example.com/bundle.js/extra',
+                'https://cdn.example.com/other.js',
+                'https://cdn.example.com/'
+            ]);
+        });
+    });
+    describe('Backend vs Client CSP differences', () => {
+        it('should allow any path for backend (hostname-only matching)', async () => {
+            const mockContext = {
+                permissions: {
+                    external: {
+                        fetch: {
+                            backend: ['https://api.example.com/specific/path']
+                        }
+                    }
+                }
+            };
+            mockGetContext.mockResolvedValue(mockContext);
+            const requiredPermissions = {
+                external: {
+                    fetch: {
+                        backend: [
+                            'https://api.example.com',
+                            'https://api.example.com/different/path',
+                            'https://api.example.com/specific/path'
+                        ]
+                    }
+                }
+            };
+            const { result } = (0, react_hooks_1.renderHook)(() => (0, usePermissions_1.usePermissions)(requiredPermissions));
+            await (0, react_hooks_1.act)(async () => {
+                await new Promise((resolve) => setTimeout(resolve, 10));
+            });
+            expect(result.current.isLoading).toBe(false);
+            expect(result.current.hasPermission).toBe(true);
+        });
+        it('should demonstrate backend allows but client blocks different paths', async () => {
+            const mockContext = {
+                permissions: {
+                    external: {
+                        fetch: {
+                            backend: ['https://api.example.com/api/'],
+                            client: ['https://api.example.com/api/']
+                        }
+                    }
+                }
+            };
+            mockGetContext.mockResolvedValue(mockContext);
+            const requiredPermissions = {
+                external: {
+                    fetch: {
+                        backend: ['https://api.example.com/private/secret.json'],
+                        client: ['https://api.example.com/private/secret.json']
+                    }
+                }
+            };
+            const { result } = (0, react_hooks_1.renderHook)(() => (0, usePermissions_1.usePermissions)(requiredPermissions));
+            await (0, react_hooks_1.act)(async () => {
+                await new Promise((resolve) => setTimeout(resolve, 10));
+            });
+            expect(result.current.isLoading).toBe(false);
+            expect(result.current.hasPermission).toBe(false);
+            // Backend should pass (hostname-only), client should fail (CSP path check)
+            expect(result.current.missingPermissions?.external?.fetch?.backend).toBeUndefined();
+            expect(result.current.missingPermissions?.external?.fetch?.client).toEqual([
+                'https://api.example.com/private/secret.json'
+            ]);
+        });
+    });
+    describe('CSP for resource types', () => {
+        it('should use CSP validation for images with paths', async () => {
+            const mockContext = {
+                permissions: {
+                    external: {
+                        images: ['https://cdn.example.com/public/']
+                    }
+                }
+            };
+            mockGetContext.mockResolvedValue(mockContext);
+            const requiredPermissions = {
+                external: {
+                    images: ['https://cdn.example.com/public/image.png', 'https://cdn.example.com/public/nested/image.jpg']
+                }
+            };
+            const { result } = (0, react_hooks_1.renderHook)(() => (0, usePermissions_1.usePermissions)(requiredPermissions));
+            await (0, react_hooks_1.act)(async () => {
+                await new Promise((resolve) => setTimeout(resolve, 10));
+            });
+            expect(result.current.isLoading).toBe(false);
+            expect(result.current.hasPermission).toBe(true);
+        });
+        it('should block images outside allowed directory', async () => {
+            const mockContext = {
+                permissions: {
+                    external: {
+                        images: ['https://cdn.example.com/public/']
+                    }
+                }
+            };
+            mockGetContext.mockResolvedValue(mockContext);
+            const requiredPermissions = {
+                external: {
+                    images: ['https://cdn.example.com/private/image.png', 'https://cdn.example.com/image.png']
+                }
+            };
+            const { result } = (0, react_hooks_1.renderHook)(() => (0, usePermissions_1.usePermissions)(requiredPermissions));
+            await (0, react_hooks_1.act)(async () => {
+                await new Promise((resolve) => setTimeout(resolve, 10));
+            });
+            expect(result.current.isLoading).toBe(false);
+            expect(result.current.hasPermission).toBe(false);
+            expect(result.current.missingPermissions?.external?.images).toEqual([
+                'https://cdn.example.com/private/image.png',
+                'https://cdn.example.com/image.png'
+            ]);
+        });
+        it('should apply CSP to all resource types', async () => {
+            const mockContext = {
+                permissions: {
+                    external: {
+                        scripts: ['https://cdn.example.com'],
+                        styles: ['https://cdn.example.com/css/'],
+                        fonts: ['https://fonts.example.com/font.woff2']
+                    }
+                }
+            };
+            mockGetContext.mockResolvedValue(mockContext);
+            const requiredPermissions = {
+                external: {
+                    scripts: ['https://cdn.example.com/bundle.js'],
+                    styles: ['https://cdn.example.com/css/main.css'],
+                    fonts: ['https://fonts.example.com/font.woff2'] // Exact match
+                }
+            };
+            const { result } = (0, react_hooks_1.renderHook)(() => (0, usePermissions_1.usePermissions)(requiredPermissions));
+            await (0, react_hooks_1.act)(async () => {
+                await new Promise((resolve) => setTimeout(resolve, 10));
+            });
+            expect(result.current.isLoading).toBe(false);
+            expect(result.current.hasPermission).toBe(true);
+        });
+    });
+    describe('URL normalization and protocol handling', () => {
+        it('should handle URLs without protocol', async () => {
+            const mockContext = {
+                permissions: {
+                    external: {
+                        fetch: {
+                            backend: ['api.example.com']
+                        }
+                    }
+                }
+            };
+            mockGetContext.mockResolvedValue(mockContext);
+            const requiredPermissions = {
+                external: {
+                    fetch: {
+                        backend: ['https://api.example.com', 'api.example.com']
+                    }
+                }
+            };
+            const { result } = (0, react_hooks_1.renderHook)(() => (0, usePermissions_1.usePermissions)(requiredPermissions));
+            await (0, react_hooks_1.act)(async () => {
+                await new Promise((resolve) => setTimeout(resolve, 10));
+            });
+            expect(result.current.isLoading).toBe(false);
+            expect(result.current.hasPermission).toBe(true);
+        });
+        it('should handle CSP secure protocol upgrades (http -> https)', async () => {
+            const mockContext = {
+                permissions: {
+                    external: {
+                        fetch: {
+                            client: ['http://example.com']
+                        }
+                    }
+                }
+            };
+            mockGetContext.mockResolvedValue(mockContext);
+            const requiredPermissions = {
+                external: {
+                    fetch: {
+                        client: ['http://example.com', 'https://example.com']
+                    }
+                }
+            };
+            const { result } = (0, react_hooks_1.renderHook)(() => (0, usePermissions_1.usePermissions)(requiredPermissions));
+            await (0, react_hooks_1.act)(async () => {
+                await new Promise((resolve) => setTimeout(resolve, 10));
+            });
+            expect(result.current.isLoading).toBe(false);
+            expect(result.current.hasPermission).toBe(true);
+        });
+        it('should not allow protocol downgrades (https -> http)', async () => {
+            const mockContext = {
+                permissions: {
+                    external: {
+                        fetch: {
+                            client: ['https://example.com']
+                        }
+                    }
+                }
+            };
+            mockGetContext.mockResolvedValue(mockContext);
+            const requiredPermissions = {
+                external: {
+                    fetch: {
+                        client: ['http://example.com']
+                    }
+                }
+            };
+            const { result } = (0, react_hooks_1.renderHook)(() => (0, usePermissions_1.usePermissions)(requiredPermissions));
+            await (0, react_hooks_1.act)(async () => {
+                await new Promise((resolve) => setTimeout(resolve, 10));
+            });
+            expect(result.current.isLoading).toBe(false);
+            expect(result.current.hasPermission).toBe(false);
+            expect(result.current.missingPermissions?.external?.fetch?.client).toEqual(['http://example.com']);
+        });
+    });
     describe('Edge cases', () => {
         it('should handle empty required permissions', async () => {
             const mockContext = {
                 permissions: {