/** * @jest-environment jsdom */ import { isPortalHostedUrl } from './shared/rpc/auth' import { buildDefaultRpcConfig, DEFAULT_RPC_HOST } from './shared/rpc/defaults' import Portal from '.' // ─── isPortalHostedUrl ─────────────────────────────────────────────────────── describe('isPortalHostedUrl', () => { describe('trusted Portal domains', () => { const trusted = [ 'https://web.portalhq.io/rpc/v1/eip155/1', 'https://portalhq.io', 'https://staging.portalhq.io/rpc/v1/eip155/1', 'https://x.portalhq.io', 'https://web.portalhq.dev/rpc/v1/eip155/1', 'https://portalhq.dev', 'https://sub.portalhq.dev', 'https://portalhq-passkey.io', 'https://sub.portalhq-passkey.io', 'https://portalhq-passkey.dev', 'https://sub.portalhq-passkey.dev', ] for (const url of trusted) { it(`returns true for ${url}`, () => { expect(isPortalHostedUrl(url)).toBe(true) }) } }) describe('untrusted / third-party domains', () => { const untrusted = [ 'https://mainnet.infura.io/v3/KEY', 'https://eth-mainnet.alchemyapi.io/v2/KEY', 'https://rpc.ankr.com/eth', 'https://notportalhq.io/rpc/v1/eip155/1', 'https://portalhq.io.evil.com/rpc', // subdomain spoofing 'https://evil-portalhq.io/rpc', // prefix attack 'https://portalhq.iomalicious.com', // suffix attack 'http://localhost:8545', // localhost — not trusted here (iframe-only) 'http://127.0.0.1:8545', ] for (const url of untrusted) { it(`returns false for ${url}`, () => { expect(isPortalHostedUrl(url)).toBe(false) }) } }) it('returns false for an invalid URL', () => { expect(isPortalHostedUrl('not-a-url')).toBe(false) expect(isPortalHostedUrl('')).toBe(false) }) it('handles trailing dot in hostname (valid DNS normalisation)', () => { // 'web.portalhq.io.' is technically valid DNS — must still be trusted expect(isPortalHostedUrl('https://web.portalhq.io./rpc/v1/eip155/1')).toBe(true) }) }) // ─── buildDefaultRpcConfig ─────────────────────────────────────────────────── describe('buildDefaultRpcConfig', () => { it('returns exactly 13 entries', () => { expect(Object.keys(buildDefaultRpcConfig(DEFAULT_RPC_HOST))).toHaveLength(13) }) it('includes all 11 expected EVM chains', () => { const cfg = buildDefaultRpcConfig(DEFAULT_RPC_HOST) const evmChains = [ 'eip155:1', 'eip155:11155111', 'eip155:137', 'eip155:80002', 'eip155:8453', 'eip155:84532', 'eip155:143', 'eip155:10143', 'eip155:10', 'eip155:42161', 'eip155:43114', ] for (const chain of evmChains) { expect(cfg).toHaveProperty(chain) } }) it('includes both Solana chains', () => { const cfg = buildDefaultRpcConfig(DEFAULT_RPC_HOST) expect(cfg).toHaveProperty('solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp') expect(cfg).toHaveProperty('solana:EtWTRABZaYq6iMfeYKouRu166VU2xqa1') }) it('does NOT include deprecated Polygon Mumbai (eip155:80001)', () => { expect(buildDefaultRpcConfig(DEFAULT_RPC_HOST)).not.toHaveProperty('eip155:80001') }) it('every URL follows the template https://{rpcHost}/rpc/v1/{namespace}/{reference}', () => { const cfg = buildDefaultRpcConfig(DEFAULT_RPC_HOST) for (const [chainId, url] of Object.entries(cfg)) { const [namespace, reference] = chainId.split(':') expect(url).toBe(`https://${DEFAULT_RPC_HOST}/rpc/v1/${namespace}/${reference}`) } }) it('uses the production host by default', () => { const cfg = buildDefaultRpcConfig() expect(cfg['eip155:1']).toBe('https://web.portalhq.io/rpc/v1/eip155/1') }) it('uses a custom rpcHost when provided', () => { const cfg = buildDefaultRpcConfig('web.portalhq.dev') for (const url of Object.values(cfg)) { expect(url).toContain('https://web.portalhq.dev/rpc/v1/') } expect(cfg['eip155:1']).toBe('https://web.portalhq.dev/rpc/v1/eip155/1') }) it('does not contain the production host when a custom host is used', () => { const cfg = buildDefaultRpcConfig('web.portalhq.dev') for (const url of Object.values(cfg)) { expect(url).not.toContain(DEFAULT_RPC_HOST) } }) }) // ─── Portal constructor — rpcConfig resolution ─────────────────────────────── describe('Portal constructor — rpcConfig resolution', () => { it('generates 13 default chains when rpcConfig is omitted', () => { const portal = new Portal({ apiKey: 'test-key' }) expect(Object.keys(portal.rpcConfig)).toHaveLength(13) }) it('uses web.portalhq.io gateway URLs when rpcConfig is omitted', () => { const portal = new Portal({ apiKey: 'test-key' }) expect(portal.rpcConfig['eip155:1']).toBe( 'https://web.portalhq.io/rpc/v1/eip155/1', ) }) it('treats {} identically to omitting rpcConfig', () => { const portalA = new Portal({ apiKey: 'test-key' }) const portalB = new Portal({ apiKey: 'test-key', rpcConfig: {} }) expect(portalB.rpcConfig).toEqual(portalA.rpcConfig) }) it('uses an explicit non-empty rpcConfig verbatim (no merge with defaults)', () => { const custom = { 'eip155:1': 'https://my-node.example.com/eth' } const portal = new Portal({ apiKey: 'test-key', rpcConfig: custom }) expect(portal.rpcConfig).toEqual(custom) expect(Object.keys(portal.rpcConfig)).toHaveLength(1) }) it('does not inject defaults alongside an explicit config', () => { const custom = { 'eip155:1': 'https://my-node.example.com/eth' } const portal = new Portal({ apiKey: 'test-key', rpcConfig: custom }) expect(portal.rpcConfig['eip155:137']).toBeUndefined() }) it('uses a custom gatewayHost in the generated default URLs', () => { const portal = new Portal({ apiKey: 'test-key', gatewayHost: 'web.portalhq.dev' }) expect(portal.rpcConfig['eip155:1']).toBe( 'https://web.portalhq.dev/rpc/v1/eip155/1', ) }) it('all default URLs contain the custom gatewayHost', () => { const portal = new Portal({ apiKey: 'test-key', gatewayHost: 'web.portalhq.dev' }) for (const url of Object.values(portal.rpcConfig)) { expect(url).toContain('https://web.portalhq.dev/rpc/v1/') } }) it('getRpcUrl returns the default URL for a standard chain', () => { const portal = new Portal({ apiKey: 'test-key' }) expect(portal.getRpcUrl('eip155:1')).toBe( 'https://web.portalhq.io/rpc/v1/eip155/1', ) }) it('getRpcUrl throws for a chain not in the config', () => { const portal = new Portal({ apiKey: 'test-key' }) expect(() => portal.getRpcUrl('eip155:99999')).toThrow() }) })