227 lines
9.5 KiB
JavaScript
227 lines
9.5 KiB
JavaScript
/**
|
|
* SandboxedFrame lifecycle tests.
|
|
* Transpiles the real component with esbuild and runs it with a minimal React
|
|
* hooks fixture. Network and JSX are stubbed; this is not React DOM / browser E2E.
|
|
*/
|
|
import assert from 'node:assert/strict'
|
|
import { test, after } from 'node:test'
|
|
import { createRequire } from 'node:module'
|
|
import { mkdtempSync, readFileSync, rmSync } from 'node:fs'
|
|
import { join } from 'node:path'
|
|
import { tmpdir } from 'node:os'
|
|
import { runInNewContext } from 'node:vm'
|
|
import { fileURLToPath } from 'node:url'
|
|
|
|
const require = createRequire(new URL('../01_content/package.json', import.meta.url))
|
|
const { buildSync } = require('esbuild')
|
|
const temp = mkdtempSync(join(tmpdir(), 'wt-sandboxed-frame-test-'))
|
|
after(() => rmSync(temp, { recursive: true, force: true }))
|
|
|
|
const componentPath = fileURLToPath(new URL('../01_content/src/client/SandboxedFrame.tsx', import.meta.url))
|
|
const bundlePath = join(temp, 'SandboxedFrame.cjs')
|
|
buildSync({
|
|
entryPoints: [componentPath], bundle: true, platform: 'node', format: 'cjs',
|
|
outfile: bundlePath, external: ['react', 'react/jsx-runtime'], jsx: 'automatic',
|
|
})
|
|
const componentCode = readFileSync(bundlePath, 'utf8')
|
|
|
|
function createHarness({ location, fetch }) {
|
|
const slots = []
|
|
let cursor = 0
|
|
let pendingEffects = []
|
|
let currentProps
|
|
let currentOutput
|
|
|
|
const jsx = (type, props) => ({ type, props: props ?? {} })
|
|
const react = {
|
|
useState(initial) {
|
|
const index = cursor++
|
|
if (!slots[index]) slots[index] = { kind: 'state', value: typeof initial === 'function' ? initial() : initial }
|
|
const slot = slots[index]
|
|
return [slot.value, value => { slot.value = typeof value === 'function' ? value(slot.value) : value }]
|
|
},
|
|
useEffect(create, deps) {
|
|
const index = cursor++
|
|
const previous = slots[index]
|
|
const same = previous?.kind === 'effect' && Array.isArray(deps) && Array.isArray(previous.deps)
|
|
&& deps.length === previous.deps.length && deps.every((value, i) => Object.is(value, previous.deps[i]))
|
|
if (!same) pendingEffects.push({ index, create, deps: Array.isArray(deps) ? [...deps] : undefined })
|
|
},
|
|
}
|
|
|
|
const module = { exports: {} }
|
|
const sandbox = {
|
|
module,
|
|
exports: module.exports,
|
|
URL,
|
|
location,
|
|
fetch,
|
|
require(id) {
|
|
if (id === 'react') return react
|
|
if (id === 'react/jsx-runtime') return { jsx, jsxs: jsx, Fragment: Symbol.for('test.fragment') }
|
|
throw new Error('unexpected external module: ' + id)
|
|
},
|
|
}
|
|
runInNewContext(componentCode, sandbox, { filename: 'SandboxedFrame.cjs' })
|
|
const { SandboxedFrame, previewTarget } = module.exports
|
|
|
|
function render(props) {
|
|
currentProps = props ?? currentProps
|
|
cursor = 0
|
|
pendingEffects = []
|
|
currentOutput = SandboxedFrame(currentProps)
|
|
for (const effect of pendingEffects) {
|
|
const previous = slots[effect.index]
|
|
if (previous?.kind === 'effect') previous.cleanup?.()
|
|
const cleanup = effect.create()
|
|
slots[effect.index] = {
|
|
kind: 'effect', deps: effect.deps,
|
|
cleanup: typeof cleanup === 'function' ? cleanup : undefined,
|
|
}
|
|
}
|
|
return currentOutput
|
|
}
|
|
|
|
function unmount() {
|
|
for (const slot of slots) if (slot?.kind === 'effect') slot.cleanup?.()
|
|
slots.length = 0
|
|
}
|
|
|
|
return { render, unmount, previewTarget, get output() { return currentOutput } }
|
|
}
|
|
|
|
const flush = async () => { for (let i = 0; i < 12; i++) await Promise.resolve() }
|
|
const siteUrl = (protocol = 'https:', host = 'dsh.example') =>
|
|
protocol + '//' + host + '/api/worktable/site/C%3A%2FProjects%2Flegacy/index.html'
|
|
const grant = token => ({ token, base: '/api/worktable/preview-static/' + token + '/' })
|
|
const response = value => ({ ok: true, status: 200, json: async () => value })
|
|
function recordingFetch(grants) {
|
|
const calls = []
|
|
const fetch = (url, init = {}) => {
|
|
const body = init.body ? JSON.parse(init.body) : {}
|
|
calls.push({ url, init, body })
|
|
if (body.revoke) return Promise.resolve({ ok: true, status: 200 })
|
|
const value = typeof grants === 'function' ? grants(body, calls.length) : grants.shift()
|
|
return Promise.resolve(typeof value?.then === 'function' ? value : response(value))
|
|
}
|
|
return { fetch, calls, revoked: () => calls.filter(call => call.body.revoke).map(call => call.body.revoke) }
|
|
}
|
|
const iframeProps = output => {
|
|
assert.equal(output?.type, 'iframe', 'expected iframe output; actual=' + JSON.stringify(output))
|
|
return output.props
|
|
}
|
|
const errorText = output => output?.props?.children?.props?.children
|
|
|
|
test('dangerous URL schemes are rejected before a preview grant is requested', async () => {
|
|
const network = recordingFetch([])
|
|
const location = { href: 'https://dsh.example/worktable', protocol: 'https:' }
|
|
const harness = createHarness({ location, fetch: network.fetch })
|
|
for (const url of ['javascript:alert(1)', 'data:text/html,unsafe', 'file:///tmp/page.html', 'blob:https://dsh.example/id', 'ftp://dsh.example/page']) {
|
|
assert.throws(() => harness.previewTarget(url, location.href), /Unsupported preview URL/)
|
|
}
|
|
harness.render({ url: 'javascript:alert(1)', reloadKey: 0 })
|
|
await flush()
|
|
const output = harness.render()
|
|
assert.equal(output.type, 'div')
|
|
assert.match(errorText(output), /Unsupported preview URL/)
|
|
assert.equal(network.calls.length, 0)
|
|
harness.unmount()
|
|
})
|
|
|
|
test('legacy site URLs request a grant for the decoded root and load the relative resource', async () => {
|
|
const token = 'a'.repeat(32)
|
|
const network = recordingFetch([grant(token)])
|
|
const location = { href: 'https://dsh.example/worktable', protocol: 'https:' }
|
|
const harness = createHarness({ location, fetch: network.fetch })
|
|
const url = 'https://dsh.example/api/worktable/site/C%3A%2FProjects%2Flegacy%20site/assets/main%20file.css?theme=dark#top'
|
|
const props = { url, title: 'legacy', reloadKey: 0 }
|
|
harness.render(props)
|
|
await flush()
|
|
const frame = iframeProps(harness.render(props))
|
|
assert.deepEqual(network.calls[0].body, { root: 'C:/Projects/legacy site' })
|
|
assert.equal(frame.src, grant(token).base + 'assets/main%20file.css?theme=dark#top')
|
|
assert.equal(frame['data-source-url'], url)
|
|
assert.equal(frame.sandbox, 'allow-scripts allow-forms allow-downloads')
|
|
harness.unmount()
|
|
assert.deepEqual(network.revoked(), [token], 'revoke calls=' + JSON.stringify(network.calls))
|
|
})
|
|
|
|
test('a grant that returns after unmount is revoked immediately', async () => {
|
|
const token = 'b'.repeat(32)
|
|
const pending = {}
|
|
pending.promise = new Promise(resolve => { pending.resolve = resolve })
|
|
const calls = []
|
|
const fetch = (url, init = {}) => {
|
|
const body = init.body ? JSON.parse(init.body) : {}
|
|
calls.push({ url, body })
|
|
return body.revoke ? Promise.resolve({ ok: true }) : pending.promise
|
|
}
|
|
const harness = createHarness({
|
|
location: { href: 'https://dsh.example/worktable', protocol: 'https:' }, fetch,
|
|
})
|
|
harness.render({ url: siteUrl(), reloadKey: 0 })
|
|
harness.unmount()
|
|
pending.resolve(response(grant(token)))
|
|
await flush()
|
|
assert.deepEqual(calls.filter(call => call.body.revoke).map(call => call.body.revoke), [token])
|
|
})
|
|
|
|
test('Desktop URL construction failure revokes the minted token immediately and only once', async () => {
|
|
const token = 'c'.repeat(32)
|
|
const network = recordingFetch([grant(token)])
|
|
const location = { href: 'dsh-app://app/worktable', protocol: 'dsh-app:' }
|
|
const harness = createHarness({ location, fetch: network.fetch })
|
|
const props = { url: siteUrl('dsh-app:', 'app'), reloadKey: 0 }
|
|
harness.render(props)
|
|
await flush()
|
|
assert.deepEqual(network.revoked(), [token])
|
|
const output = harness.render(props)
|
|
assert.equal(output.type, 'div')
|
|
assert.match(errorText(output), /Desktop Host preview URL unavailable/)
|
|
harness.unmount()
|
|
assert.deepEqual(network.revoked(), [token], 'unmount must not revoke the already-cleared token again')
|
|
})
|
|
|
|
test('reload revokes the previous token and uses only the newly issued token', async () => {
|
|
const first = 'd'.repeat(32)
|
|
const second = 'e'.repeat(32)
|
|
const network = recordingFetch([grant(first), grant(second)])
|
|
const harness = createHarness({
|
|
location: { href: 'https://dsh.example/worktable', protocol: 'https:' }, fetch: network.fetch,
|
|
})
|
|
const props = { url: siteUrl(), reloadKey: 0 }
|
|
harness.render(props)
|
|
await flush()
|
|
assert.ok(iframeProps(harness.render(props)).src.includes(first))
|
|
const reloadProps = { ...props, reloadKey: 1 }
|
|
harness.render(reloadProps)
|
|
assert.deepEqual(network.revoked(), [first], 'reload cleanup must revoke the old token before the new grant settles')
|
|
await flush()
|
|
const nextFrame = iframeProps(harness.render(reloadProps))
|
|
assert.ok(nextFrame.src.includes(second))
|
|
assert.equal(nextFrame.src.includes(first), false)
|
|
harness.unmount()
|
|
assert.deepEqual(network.revoked(), [first, second])
|
|
})
|
|
|
|
test('an unavailable preview authorization service renders an error without navigating', async () => {
|
|
const calls = []
|
|
const fetch = async (url, init = {}) => {
|
|
calls.push({ url, body: init.body ? JSON.parse(init.body) : {} })
|
|
return { ok: false, status: 503 }
|
|
}
|
|
const harness = createHarness({
|
|
location: { href: 'https://dsh.example/worktable', protocol: 'https:' }, fetch,
|
|
})
|
|
const props = { url: siteUrl(), reloadKey: 0 }
|
|
harness.render(props)
|
|
await flush()
|
|
const output = harness.render(props)
|
|
assert.equal(output.type, 'div')
|
|
assert.match(errorText(output), /Preview HTTP 503/)
|
|
assert.equal(calls.length, 1)
|
|
assert.deepEqual(calls[0].body, { root: 'C:/Projects/legacy' })
|
|
assert.equal(calls.some(call => call.body.revoke), false)
|
|
harness.unmount()
|
|
})
|