108 lines
5.6 KiB
JavaScript
108 lines
5.6 KiB
JavaScript
import assert from 'node:assert/strict'
|
|
import { test, after, afterEach } from 'node:test'
|
|
import { createRequire } from 'node:module'
|
|
import { mkdtempSync, writeFileSync, mkdirSync, rmSync, symlinkSync, renameSync, truncateSync, unlinkSync } from 'node:fs'
|
|
import { join } from 'node:path'
|
|
import { tmpdir } from 'node:os'
|
|
import { pathToFileURL, fileURLToPath } from 'node:url'
|
|
const require=createRequire(new URL('../01_content/package.json',import.meta.url))
|
|
const {build}=require('esbuild')
|
|
const temp=mkdtempSync(join(tmpdir(),'wt-preview-test-'))
|
|
after(()=>rmSync(temp,{recursive:true,force:true}))
|
|
// Instrument filesystem scheduling only in the test bundle; production has no hooks.
|
|
afterEach(()=>{ delete globalThis.__wtPreviewHooks })
|
|
await build({
|
|
entryPoints:[fileURLToPath(new URL('../01_content/src/previewFiles.ts',import.meta.url))],
|
|
bundle:true,platform:'node',format:'esm',outfile:join(temp,'preview.mjs'),
|
|
plugins:[{name:'filesystem-race-fixture',setup(build){
|
|
build.onResolve({filter:/^node:fs\/promises$/},args=>args.namespace==='race-fixture'?undefined:{path:args.path,namespace:'race-fixture'})
|
|
build.onLoad({filter:/.*/,namespace:'race-fixture'},()=>({loader:'js',contents:`
|
|
import * as fs from 'node:fs/promises';
|
|
export const realpath=fs.realpath, stat=fs.stat;
|
|
export async function open(...args) {
|
|
await globalThis.__wtPreviewHooks?.beforeOpen?.();
|
|
const h=await fs.open(...args);
|
|
await globalThis.__wtPreviewHooks?.afterOpen?.();
|
|
const read=h.read.bind(h);
|
|
h.read=async(...args)=>{await globalThis.__wtPreviewHooks?.beforeRead?.();return read(...args)};
|
|
return h;
|
|
}`}))
|
|
}}],
|
|
})
|
|
const {createPreviewFiles,PREVIEW_MAX_BYTES}=await import(pathToFileURL(join(temp,'preview.mjs')).href)
|
|
const root=join(temp,'root'),outside=join(temp,'outside');mkdirSync(root);mkdirSync(outside)
|
|
writeFileSync(join(root,'index.html'),'fixture');writeFileSync(join(outside,'outside.txt'),'outside')
|
|
test('Preview reads only its canonical root, including directory indexes',async()=>{
|
|
const store=createPreviewFiles(),token=await store.mount(root)
|
|
assert.equal((await store.file(token,[])).path,join(root,'index.html'))
|
|
assert.equal(await store.file('invented-token',['index.html']),null)
|
|
for(const parts of [['..','outside','outside.txt'],['..\\outside\\outside.txt'],['/outside'],['bad\0']]) assert.equal(await store.file(token,parts),null)
|
|
symlinkSync(outside,join(root,'escape'),process.platform==='win32'?'junction':'dir')
|
|
assert.equal(await store.file(token,['escape','outside.txt']),null)
|
|
rmSync(join(root,'escape'))
|
|
})
|
|
test('Capabilities expire after idle time; revocation and disposal remove them',async()=>{
|
|
let now=0;const store=createPreviewFiles(()=>now),a=await store.mount(root)
|
|
now=29*60*1000;assert.ok(await store.file(a,['index.html']))
|
|
now+=30*60*1000;assert.equal(await store.file(a,['index.html']),null)
|
|
const b=await store.mount(root);store.revoke(b);assert.equal(await store.file(b,[]),null)
|
|
const c=await store.mount(root);store.clear();assert.equal(await store.file(c,[]),null)
|
|
})
|
|
test('Reject relative roots and bound the live capability table',async()=>{
|
|
const store=createPreviewFiles();await assert.rejects(store.mount('.'),/absolute/)
|
|
for(let i=0;i<128;i++)await store.mount(root)
|
|
await assert.rejects(store.mount(root),/Too many/)
|
|
})
|
|
|
|
|
|
test('Read content through the checked handle; HEAD omits the body and enforces the limit',async()=>{
|
|
const store=createPreviewFiles(),token=await store.mount(root)
|
|
assert.equal((await store.file(token,['index.html'])).data.toString(),'fixture')
|
|
const head=await store.file(token,['index.html'],true)
|
|
assert.equal(head.size,7);assert.equal(head.data,undefined)
|
|
const large=join(root,'large.bin');writeFileSync(large,'x');truncateSync(large,PREVIEW_MAX_BYTES+1)
|
|
await assert.rejects(store.file(token,['large.bin'],true),e=>e.status===413)
|
|
})
|
|
|
|
test('A directory replaced by a junction after path checking cannot return outside content',async()=>{
|
|
const slot=join(root,'slot'),moved=join(root,'slot-original')
|
|
mkdirSync(slot);writeFileSync(join(slot,'outside.txt'),'inside')
|
|
const store=createPreviewFiles(),token=await store.mount(root)
|
|
globalThis.__wtPreviewHooks={beforeOpen(){
|
|
delete globalThis.__wtPreviewHooks
|
|
renameSync(slot,moved);symlinkSync(outside,slot,process.platform==='win32'?'junction':'dir')
|
|
}}
|
|
try {assert.equal(await store.file(token,['slot','outside.txt']),null)}
|
|
finally {unlinkSync(slot);renameSync(moved,slot)}
|
|
})
|
|
|
|
test('Replacing the named file after open rejects the response, even with an open old handle',async()=>{
|
|
const name=join(root,'replace.txt'),old=join(root,'replace-original.txt')
|
|
writeFileSync(name,'original')
|
|
const store=createPreviewFiles(),token=await store.mount(root)
|
|
globalThis.__wtPreviewHooks={afterOpen(){
|
|
delete globalThis.__wtPreviewHooks
|
|
renameSync(name,old);writeFileSync(name,'replacement')
|
|
}}
|
|
assert.equal(await store.file(token,['replace.txt']),null)
|
|
})
|
|
|
|
test('Growth after stat cannot produce a response over 40 MiB',async()=>{
|
|
const name=join(root,'growing.bin');writeFileSync(name,'small')
|
|
const store=createPreviewFiles(),token=await store.mount(root)
|
|
globalThis.__wtPreviewHooks={beforeRead(){
|
|
delete globalThis.__wtPreviewHooks
|
|
truncateSync(name,PREVIEW_MAX_BYTES+1)
|
|
}}
|
|
await assert.rejects(store.file(token,['growing.bin']),e=>e.status===413)
|
|
})
|
|
|
|
test('Revocation during a pending read prevents the response',async()=>{
|
|
const store=createPreviewFiles(),token=await store.mount(root)
|
|
globalThis.__wtPreviewHooks={beforeRead(){
|
|
delete globalThis.__wtPreviewHooks
|
|
store.revoke(token)
|
|
}}
|
|
assert.equal(await store.file(token,['index.html']),null)
|
|
})
|