Files
archy/neode-ui/e2e/native-aiui-security.spec.ts
T

142 lines
10 KiB
TypeScript

import { test, expect } from '@playwright/test'
// Runs only against an isolated Vite server. All RPC is stubbed in the fixture;
// no installed-node credential, payment or app operation is reachable.
test.beforeEach(async ({ page }) => {
page.on('pageerror', error => console.error('Native fixture page error:', error.message))
await page.route('**/*', route => new URL(route.request().url()).origin === new URL(process.env.ARCHY_BASE_URL ?? 'http://localhost:8100').origin ? route.continue() : route.abort())
await page.route('**/aiui/api/**', route => route.fulfill({ json: {} }))
await page.goto('/e2e/fixtures/native-aiui.html', { waitUntil: 'domcontentloaded' })
})
test('native shell shares one application and retains drafts across tab changes', async ({ page }, testInfo) => {
await expect(page.locator('[data-aiui-native]')).toBeVisible({ timeout: 90_000 })
expect(await page.locator('iframe').count()).toBe(0)
expect(await page.locator('[data-v-app]').count()).toBe(1)
expect(await page.evaluate(() => document.documentElement.style.getPropertyValue('--color-accent'))).toBe('')
const composer = page.locator('textarea[placeholder="Message AIUI..."]').first()
await composer.fill('Keep this native draft')
await page.evaluate(() => (window as any).toggleNative())
await page.evaluate(() => (window as any).toggleNative())
await expect(composer).toHaveValue('Keep this native draft')
const before = await page.evaluate(() => (window as any).calls.length)
await page.evaluate(() => {
window.postMessage({ type: 'action:request', id: 'malicious', action: 'install-app', params: { appId: 'evil' } }, '*')
window.postMessage({ type: 'chat:request', id: 'malicious', text: 'pay money' }, '*')
})
await page.waitForTimeout(150)
expect(await page.evaluate(() => (window as any).calls.slice(-2).some((call: any) => call.params?.appId === 'evil' || call.params?.text === 'pay money'))).toBe(false)
expect(before).toBeGreaterThan(0)
await page.screenshot({ path: testInfo.outputPath('native-desktop.png') })
})
test('malicious rich HTML does not run and generated code cannot read host credentials or call the broker', async ({ page }) => {
await expect(page.locator('[data-aiui-native]')).toBeVisible({ timeout: 90_000 })
await page.evaluate(async () => {
document.cookie = 'archipelago_csrf=native-test-secret; path=/'
await (window as any).prepareNativePayload()
})
await page.locator('#payload-fixture').getByRole('button', { name: 'Run', exact: true }).click()
await expect(page.getByTestId('code-runner-console').filter({ hasText: 'host access blocked' })).toBeVisible()
await expect(page.getByTestId('code-runner-console').filter({ hasText: 'network blocked' })).toBeVisible()
expect(await page.evaluate(() => (window as any).richExecuted)).toBe(false)
expect(await page.locator('#payload-fixture iframe').getAttribute('sandbox')).toBe('allow-scripts')
expect(await page.locator('#payload-fixture').textContent()).not.toContain('native-test-secret')
expect(await page.evaluate(() => (window as any).calls.some((call: any) => call.params?.text === 'malicious payment'))).toBe(false)
})
test('existing encrypted conversations and vault survive wrong and correct native unlock', async ({ page }) => {
await expect(page.locator('[data-aiui-native]')).toBeVisible({ timeout: 90_000 })
const before = await page.evaluate(() => (window as any).seedNativeStorage())
await page.reload({ waitUntil: 'domcontentloaded' })
const passphrase = page.getByPlaceholder('Enter passphrase…')
await passphrase.fill('wrong fixture password')
await page.getByRole('button', { name: 'Unlock', exact: true }).click()
await expect(page.getByText('Encryption failed', { exact: false })).toBeVisible()
expect(await page.evaluate(() => (window as any).readNativeRecord('aiui-vault', 'api-keys', 'claude'))).toEqual({ provider: 'claude', encrypted: true, value: before.vault })
await passphrase.fill('fixture passphrase')
await page.getByRole('button', { name: 'Unlock', exact: true }).click()
await expect(page.getByText('Keep this conversation', { exact: true }).first()).toBeVisible()
expect((await page.evaluate(() => (window as any).readNativeRecord('aiui-store', 'conversations', 'preserved'))).encrypted).toBe(before.encrypted)
expect(await page.evaluate(() => localStorage.getItem('host-preservation-control'))).toBe('unchanged')
expect(await page.evaluate(() => JSON.stringify((window as any).calls))).not.toContain('fixture-private-key')
await page.evaluate(() => (window as any).remountNative())
await expect(page.getByPlaceholder('Enter passphrase…')).toBeVisible()
await page.getByRole('button', { name: 'Skip', exact: true }).click()
await expect(page.getByText('Keep this conversation', { exact: true })).toHaveCount(0)
expect(await page.evaluate(() => (window as any).attemptLockedOverwrite())).toBe(true)
expect((await page.evaluate(() => (window as any).readNativeRecord('aiui-store', 'conversations', 'preserved'))).encrypted).toBe(before.encrypted)
})
test('remote video SDK stays opaque while play, pause, seek and close remain functional', async ({ page }) => {
await page.route('https://www.youtube.com/iframe_api', route => route.fulfill({ contentType: 'application/javascript', body: `
let escaped=false;try{void parent.document.cookie;escaped=true}catch{}
parent.postMessage({type:'fixture-sdk-escape',escaped},'*');
window.YT={Player:class { constructor(_target,options){this.options=options;this.time=0;setTimeout(()=>options.events.onReady(),0)} playVideo(){this.options.events.onStateChange({data:1})} pauseVideo(){this.options.events.onStateChange({data:2})} seekTo(time){this.time=time} getCurrentTime(){return this.time} getDuration(){return 300} }};
window.onYouTubeIframeAPIReady();` }))
await expect(page.locator('[data-aiui-native]')).toBeVisible({ timeout: 90_000 })
await page.evaluate(() => (window as any).prepareVideoSandbox())
await expect.poll(() => page.evaluate(() => (window as any).sdkEvents.ready)).toBe(true)
expect(await page.evaluate(() => (window as any).sdkEvents.escaped)).toBe(false)
expect(await page.locator('head script[src*="youtube"]').count()).toBe(0)
const frame = page.locator('#video-sdk-fixture iframe')
expect(await frame.getAttribute('sandbox')).toBe('allow-scripts allow-presentation')
await page.evaluate(() => { (window as any).sdkPlayer.playVideo(); (window as any).sdkPlayer.pauseVideo(); (window as any).sdkPlayer.seekTo(42) })
await expect.poll(() => page.evaluate(() => (window as any).sdkEvents.states)).toEqual([1, 2])
await expect.poll(() => page.evaluate(() => (window as any).sdkPlayer.getCurrentTime())).toBe(42)
expect(await page.evaluate(() => (window as any).sdkPlayer.getDuration())).toBe(300)
await page.evaluate(() => (window as any).sdkPlayer.destroy())
await expect(frame).toHaveCount(0)
})
test('structured content cards reject executable link schemes', async ({ page }) => {
await expect(page.locator('[data-aiui-native]')).toBeVisible({ timeout: 90_000 })
await page.evaluate(() => (window as any).prepareUnsafeLink())
const link = page.locator('#payload-fixture a').first()
await expect(link).not.toHaveAttribute('href')
await link.click()
expect(await page.evaluate(() => (window as any).richExecuted)).toBe(false)
})
function syntheticPdf(): Buffer {
const content = 'BT /F1 16 Tf 20 100 Td (Patched worker extraction) Tj ET\n0 0 0 rg 20 20 100 20 re f\n'
const objects = [
'<< /Type /Catalog /Pages 2 0 R >>',
'<< /Type /Pages /Kids [3 0 R] /Count 1 >>',
'<< /Type /Page /Parent 2 0 R /MediaBox [0 0 300 150] /Resources << /Font << /F1 4 0 R >> >> /Contents 5 0 R >>',
'<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>',
`<< /Length ${Buffer.byteLength(content)} >>\nstream\n${content}endstream`,
]
let document = '%PDF-1.4\n'
const offsets = [0]
for (const [index, body] of objects.entries()) { offsets.push(Buffer.byteLength(document)); document += `${index + 1} 0 obj\n${body}\nendobj\n` }
const xref = Buffer.byteLength(document)
document += `xref\n0 ${objects.length + 1}\n0000000000 65535 f \n${offsets.slice(1).map(offset => `${offset.toString().padStart(10, '0')} 00000 n \n`).join('')}trailer\n<< /Size ${objects.length + 1} /Root 1 0 R >>\nstartxref\n${xref}\n%%EOF\n`
return Buffer.from(document)
}
test('patched PDF worker renders and extracts while patched KaTeX preserves safe math', async ({ page }) => {
const workers: string[] = []
page.on('worker', worker => workers.push(worker.url()))
await page.route('**/e2e/fixtures/native-document.pdf', route => route.fulfill({ contentType: 'application/pdf', body: syntheticPdf() }))
await expect(page.locator('[data-aiui-native]')).toBeVisible({ timeout: 90_000 })
await page.evaluate(() => (window as any).preparePdfAndMath())
const viewer = page.locator('#payload-fixture .pdf-viewer')
await expect(viewer.getByText('1 / 1', { exact: true })).toBeVisible()
await expect(viewer.getByText('Loading PDF...', { exact: true })).toHaveCount(0)
await expect(viewer.getByText('Failed to', { exact: false })).toHaveCount(0)
const canvas = viewer.locator('canvas')
await expect(canvas).toBeVisible()
expect(await canvas.evaluate(element => { const canvas = element as HTMLCanvasElement; return [...canvas.getContext('2d')!.getImageData(20, 110, 100, 20).data].filter((value, index) => index % 4 !== 3 && value < 40).length })).toBeGreaterThan(1000)
expect(workers.some(url => url.includes('pdf.worker'))).toBe(true)
const extracted = await page.evaluate(() => (window as any).extractFixturePdf())
expect(extracted.version).toMatch(/^6\./)
expect(extracted.text).toContain('Patched worker extraction')
expect(await page.evaluate(() => (window as any).fixtureKatexVersion)).toMatch(/^0\.18\./)
await expect(page.locator('#rich-fixture math').first()).toBeAttached()
await expect(page.locator('#rich-fixture msup')).toHaveCount(3)
await expect(page.locator('#rich-fixture [href^="javascript:"]')).toHaveCount(0)
expect(await page.evaluate(() => Boolean((window as any).richExecuted))).toBe(false)
})