@forge/react
11.9.011.9.1-next.0
out/hooks/__test__/usePermissions.test.jsout/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: {